├── .commitlintrc.json ├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── pull_request_template.md └── workflows │ └── gh_docker.yml ├── .gitignore ├── .gitpod.yml ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .scripts ├── copyright.js └── start_voice.sh ├── CHANGELOG.md ├── Dockerfile ├── MKPLACE.md ├── README.md ├── assets ├── cx_logo.png ├── es_logo.png └── youtube-3.svg ├── bin ├── run └── run.cmd ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src ├── cerebro │ ├── cerebro.ts │ ├── effects.ts │ ├── helper.ts │ ├── index.ts │ └── types.ts ├── config.ts ├── events │ ├── emitter.ts │ ├── server.ts │ └── types.ts ├── file-retention │ ├── cron.ts │ ├── index.ts │ └── task.ts ├── index.ts ├── intents │ ├── df_utils.ts │ ├── dialogflow_cx.ts │ ├── dialogflow_es.ts │ ├── engines.ts │ └── types.ts ├── telemetry │ └── index.ts ├── types.ts ├── util.ts └── voice.ts ├── test └── rox.test.ts └── tsconfig.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Example of environment variables 2 | # See the README for additional environment variables 3 | EVENTS_SERVER_ENABLED=true 4 | OTL_EXPORTER_ZIPKIN_URL=http://localhost:9412/api/v2/spans 5 | OTL_EXPORTER_JAEGER_URL=http://localhost:14268/api/traces 6 | LOGS_LEVEL=verbose 7 | 8 | FILE_RETENTION_POLICY_ENABLED=true 9 | FILE_RETENTION_POLICY_DIRECTORY= 10 | FILE_RETENTION_POLICY_CRON_EXPRESSION="0 0 * * *" 11 | FILE_RETENTION_POLICY_MAX_AGE=24 12 | FILE_RETENTION_POLICY_EXTENSION=".sln24" 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules/* 3 | src/cerebro/* 4 | src/events/* 5 | src/file-retention/* 6 | src/intents/* 7 | src/telemetry/* 8 | src/config.ts 9 | src/index.ts 10 | src/types.ts 11 | src/voice.ts -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 2020, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint", 10 | "no-loops", 11 | "prettier", 12 | "notice" 13 | ], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "google", 18 | "plugin:@typescript-eslint/recommended", 19 | "prettier" 20 | ], 21 | "rules": { 22 | "quotes": [ 23 | "error", 24 | "double" 25 | ], 26 | "notice/notice": [ 27 | "error", 28 | { 29 | "mustMatch": "Licensed under the MIT License", 30 | "templateFile": ".scripts/copyright.js" 31 | } 32 | ], 33 | "no-loops/no-loops": 2, 34 | "no-console": 1, 35 | "prettier/prettier": 2 36 | } 37 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [fonoster,psanders] 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 8 | 9 | ## Type of change 10 | 11 | 14 | 15 | - [ ] Bug fix (non-breaking change which fixes an issue) 16 | - [ ] New feature (non-breaking change which adds functionality) 17 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 18 | - [ ] This change requires a documentation update 19 | 20 | ## How Has This Been Tested? 21 | 22 | 27 | 28 | ## Checklist: 29 | 30 | 31 | 32 | - [ ] I have performed a self-review of my code 33 | - [ ] I have commented my code, particularly in hard-to-understand areas 34 | - [ ] I have made corresponding changes to the documentation 35 | - [ ] My changes generate no new warnings 36 | - [ ] I have added tests that prove my fix is effective or that my feature works 37 | - [ ] New and existing unit tests pass locally with my changes 38 | - [ ] Any dependent changes have been merged and published in downstream modules 39 | -------------------------------------------------------------------------------- /.github/workflows/gh_docker.yml: -------------------------------------------------------------------------------- 1 | name: publish to docker hub 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v1 9 | - name: Get the version 10 | id: get_version 11 | run: echo ::set-output name=VERSION::$(node -e "console.log(require('./package.json').version)") 12 | - name: Publish 13 | uses: elgohr/Publish-Docker-Github-Action@v5 14 | with: 15 | name: fonoster/rox 16 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 17 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 18 | workdir: . 19 | tags: "latest, ${{ steps.get_version.outputs.VERSION }}" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | .config 3 | .nyc_output 4 | .env 5 | dist 6 | node_modules 7 | tsconfig.tsbuildinfo 8 | 9 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | ## Learn more about this file at 'https://www.gitpod.io/docs/references/gitpod-yml' 2 | ## 3 | ## This '.gitpod.yml' file when placed at the root of a project instructs 4 | ## Gitpod how to prepare & build the project, start development environments 5 | ## and configure continuous prebuilds. Prebuilds when enabled builds a project 6 | ## like a CI server so you can start coding right away - no more waiting for 7 | ## dependencies to download and builds to finish when reviewing pull-requests 8 | ## or hacking on something new. 9 | ## 10 | tasks: 11 | - name: Setup & start 12 | init: npm install 13 | command: LOGS_LEVEL=verbose npm start 14 | 15 | ports: 16 | - port: 3000 17 | visibility: public 18 | onOpen: notify 19 | - port: 3001 20 | visibility: public 21 | onOpen: notify 22 | - port: 9090 23 | visibility: public 24 | onOpen: notify 25 | 26 | github: 27 | prebuilds: 28 | master: true 29 | branches: true 30 | pullRequests: true 31 | pullRequestsFromForks: true 32 | addCheck: true 33 | addComment: false 34 | addBadge: true -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | npm run format 6 | npm run lint -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/package-lock.json 3 | **/*.md 4 | **/generated/* 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "bracketSpacing": true, 7 | "useTabs": false 8 | } -------------------------------------------------------------------------------- /.scripts/copyright.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) <%= YEAR %> by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster 4 | * 5 | * This file is part of nodejs-voiceapp 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ -------------------------------------------------------------------------------- /.scripts/start_voice.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | OTL_EXPORTER_ZIPKIN_URL="http://localhost:9412/api/v2/spans" \ 3 | OTL_EXPORTER_JAEGER_URL="http://localhost:14268/api/traces" \ 4 | LOGS_LEVEL=verbose \ 5 | EVENTS_SERVER_ENABLED=true ./bin/run 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 0.0.13 4 | 5 | - Fixed not stopping playback 6 | 7 | # 0.0.10 8 | 9 | - Now closing mediapipe for transfer effect 10 | - Allowing to insert media for transfer effect(transfer, busy, and noanswer) 11 | - Every call gets its own intents engine sessionId 12 | 13 | # 0.0.9 14 | 15 | - Implements Dialoflog Phone Gateway behavior 16 | - Implements dynamic project configuration via HTTP 17 | 18 | # 0.0.8 19 | 20 | - Now starting Prometheus by default 21 | - Added configuration parameter to select Dialogflow Platform (Telephony, or Unspecified) 22 | 23 | # 0.0.7 24 | 25 | - Added support for opentelemetry 26 | 27 | # 0.0.6 28 | 29 | - Now including telephony payload for Dialogflow ES 30 | 31 | # 0.0.5 32 | 33 | - Added the ability to start a ngrok tunnel 34 | - You can now install rox as a global command 35 | 36 | # 0.0.4 37 | 38 | Initial release 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## 2 | ## Build and pack the service 3 | ## 4 | FROM fonoster/base as builder 5 | 6 | COPY . /scripts 7 | RUN ./install.sh 8 | 9 | ## 10 | ## Runner 11 | ## 12 | FROM fonoster/base as runner 13 | 14 | COPY --from=builder /scripts/fonoster-* . 15 | 16 | RUN apk add --no-cache --update curl git tini npm nodejs python3 make g++ \ 17 | && npm install -g fonoster-*.tgz \ 18 | && apk del npm git python3 make g++ 19 | 20 | USER fonoster 21 | 22 | EXPOSE 3000/tcp 23 | EXPOSE 3001/tcp 24 | 25 | HEALTHCHECK CMD curl --fail http://localhost:3000/ping || exit 1 26 | -------------------------------------------------------------------------------- /MKPLACE.md: -------------------------------------------------------------------------------- 1 | ![publish to docker](https://github.com/fonoster/rox/workflows/publish%20to%20docker%20hub/badge.svg) 2 | 3 | The Dialogflow connector is for creating Programmable Voice Applications without having to do any coding. It ships as a docker container and has support for both editions of Dialogfow. 4 | 5 | See the [tutorial](https://learn.fonoster.com/docs/tutorials/connecting_with_dialogflow). 6 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🤖 Rox AI: A connector for Dialogflow ES/CX 2 | 3 | Contribute with Gitpod ![publish to docker](https://github.com/fonoster/rox/workflows/publish%20to%20docker%20hub/badge.svg) 4 | 5 | This repository contains a dockerized distribution of Rox AI. Also, see [Fonoster](https://github.com/fonoster/fonoster). 6 | 7 | ## Youtube Demo 8 | 9 | See a Car Rental demo using Rox AI 10 | 11 |
12 | Demo of Rox AI 13 |
14 | 15 | ## Available Versions 16 | 17 | You can see all images available to pull from Docker Hub via the [Tags](https://hub.docker.com/repository/docker/fonoster/rox/tags?page=1) page. Docker tag names that begin with a "change type" word such as task, bug, or feature are available for testing and may be removed at any time. 18 | 19 | > The version is the same of the Asterisk this is image is based on 20 | 21 | ## Installation 22 | 23 | You can clone this repository and manually build it. 24 | 25 | ``` 26 | cd fonoster/rox\:%%VERSION%% 27 | docker build -t fonoster/rox:%%VERSION%% . 28 | ``` 29 | 30 | Otherwise, you can pull this image from the docker index. 31 | 32 | ``` 33 | docker pull fonoster/rox:%%VERSION%% 34 | ``` 35 | 36 | ## Usage Example 37 | 38 | The following is a basic example of using this image. 39 | 40 | ``` 41 | docker run -it -p 3000:3000 fonoster/rox:latest 42 | ``` 43 | 44 | ## Deploying in development mode with Gitpod 45 | 46 | One-click interactive deployment will familiarize you with the server in development mode. 47 | 48 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/fonoster/rox) 49 | 50 | ## Specs for Dialogflow backend 51 | 52 | To allow for seamless integration between Dialogflow and Rox, we introduced the concept of Effects. Effects are actions sent from Dialogflow to Rox so you don't have to program the behavior every time. All you need to do is send the Effect's payload and Rox will react accordingly. 53 | 54 | You can set multiple responses in Dialogflow. The Effects will run in sequence. 55 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | 84 | 85 | 86 | 91 | 92 | 101 | 102 | 103 | 108 | 109 | 123 | 124 | 125 | 130 | 131 | 144 | 145 |
Effect ID Description Payload Example
62 | 63 | `say` 64 | 65 | The Effect will randomly pick a textual response and play it back to the user 68 | 69 | ```json 70 | { 71 | "effect": "say", 72 | "parameters": { 73 | "responses": [ 74 | "Goodbye!", 75 | "Talk later", 76 | "Bye!", 77 | "Have a good one!" 78 | ] 79 | } 80 | } 81 | ``` 82 | 83 |
87 | 88 | `hangup` 89 | 90 | The hangup Effect will close the call 93 | 94 | ```json 95 | { 96 | "effect": "hangup" 97 | } 98 | ``` 99 | 100 |
104 | 105 | `send_data` 106 | 107 | Use this Effect send arbitrary data to the client. Note that this only works with clients that subscribe for events 110 | 111 | ```json 112 | { 113 | "effect": "send_data", 114 | "parameters": { 115 | "type": "map", 116 | "icon": "https://freeicons.net/icons/map.png", 117 | "link": "https://goo.gl/maps/YTum2VeZSQwNB4ik6" 118 | } 119 | } 120 | ``` 121 | 122 |
126 | 127 | `transfer` 128 | 129 | Forward call to a different endpoint 132 | 133 | ```json 134 | { 135 | "effect": "transfer", 136 | "parameters": { 137 | "destination": "17853178070", 138 | "record": true 139 | } 140 | } 141 | ``` 142 | 143 |
146 | 147 | > Notes: The parameter `type` is set to map in the example, but you can send anything that makes sense to the client. If the parameter `allRequiredParamsPresent` is set to true, the fulfillmentText will take precedence over the custom effects. 148 | 149 | ## Environment Variables 150 | 151 | Environment variables are used in the entry point script to render configuration templates. You can specify the values of these variables during `docker run`, `docker-compose up`, or in Kubernetes manifests in the `env` array. 152 | 153 | - `DEFAULT_LANGUAGE_CODE` - Sets the default language for the application. Defaults to `en-US` 154 | - `OTL_EXPORTER_PROMETHEUS_PORT` - Sets Prometheus port. Defaults to `9090` 155 | - `OTL_EXPORTER_PROMETHEUS_ENDPOINT` - Sets Prometheus endpoint. Defaults to `/metrics` 156 | - `OTL_EXPORTER_JAEGER_URL` - If set, it will send traces to Jaeger 157 | - `OTL_EXPORTER_GCP_ENABLED` - If set, it will send traces to GCP 158 | - `OTL_EXPORTER_ZIPKIN_URL` - If set, it will send traces to Zipkin 159 | - `EVENTS_SERVER_ENABLED` - Activates the Events Server for socket connection. Defaults to `false` 160 | 161 | ## Exposed Ports 162 | 163 | - `3000` - Port to start a session request 164 | - `3001` - Port to subscribe for `send_data` effects 165 | - `9090` - Default Prometheus port 166 | 167 | ## Volumes 168 | 169 | - None 170 | 171 | ## TODO 172 | 173 | - [ ] Add authentication to secure the events port 174 | - [ ] Include a `--log-level` flag (You can enable logs using the env LOGS_LEVEL) 175 | - [ ] Include a `--app-port` so we can change the default voice application port 176 | - [ ] Include a `--events-port` so we can change the default events port 177 | 178 | ## Contributing 179 | 180 | Please read [CONTRIBUTING.md](https://github.com/fonoster/rox/blob/main/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 181 | 182 | ## Authors 183 | 184 | - [Pedro Sanders](https://github.com/psanders) 185 | 186 | See also the list of contributors who [participated](https://github.com/fonoster/rox/contributors) in this project. 187 | 188 | ## License 189 | 190 | Copyright (C) 2023 by Fonoster Inc. MIT License (see [LICENSE](https://github.com/fonoster/rox/blob/main/LICENSE) for details). 191 | -------------------------------------------------------------------------------- /assets/cx_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fonoster/rox/e76ddc56b80cd852fa89657c67bdaf32f06e3b72/assets/cx_logo.png -------------------------------------------------------------------------------- /assets/es_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fonoster/rox/e76ddc56b80cd852fa89657c67bdaf32f06e3b72/assets/es_logo.png -------------------------------------------------------------------------------- /assets/youtube-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const path = require('path') 5 | const project = path.join(__dirname, '../tsconfig.json') 6 | const dev = fs.existsSync(project) 7 | 8 | if (dev) { 9 | require('ts-node').register({project}) 10 | } 11 | 12 | require(`../${dev ? 'src' : 'dist'}`).run() 13 | .catch(require('@oclif/errors/handle')) 14 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | rox: 5 | image: fonoster/rox:latest 6 | ports: 7 | - 3000:3000 8 | - 3001:3001 9 | environment: 10 | LOGS_LEVEL: verbose 11 | 12 | ngrok: 13 | image: wernight/ngrok:latest 14 | ports: 15 | - 4040:4040 16 | environment: 17 | NGROK_PROTOCOL: http 18 | NGROK_PORT: rox:3000 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fonoster/rox", 3 | "version": "0.3.7", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "bin": { 8 | "run": "./bin/run", 9 | "rox": "./bin/run" 10 | }, 11 | "scripts": { 12 | "build": "tsc --build ./tsconfig.json", 13 | "start": ".scripts/start_voice.sh", 14 | "posttest": "eslint . --ext .ts --config .eslintrc", 15 | "prepack": "npm install && rimraf lib && tsc -b && oclif-dev readme", 16 | "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"", 17 | "version": "oclif-dev readme && git add README.md", 18 | "lint": "eslint src --ext .ts --fix", 19 | "format": "prettier --write src", 20 | "prepare": "husky install" 21 | }, 22 | "dependencies": { 23 | "@fonoster/apps": "^0.3.22", 24 | "@fonoster/googleasr": "^0.3.22", 25 | "@fonoster/googletts": "^0.3.22", 26 | "@fonoster/logger": "^0.3.22", 27 | "@fonoster/secrets": "^0.3.22", 28 | "@fonoster/voice": "^0.3.22", 29 | "@google-cloud/dialogflow": "^4.3.1", 30 | "@google-cloud/dialogflow-cx": "^2.13.0", 31 | "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0", 32 | "@oclif/command": "^1.8.12", 33 | "@oclif/config": "^1.8.2", 34 | "@oclif/errors": "^1.3.4", 35 | "@oclif/plugin-help": "^3", 36 | "@opentelemetry/core": "^1.11.0", 37 | "@opentelemetry/exporter-jaeger": "^1.11.0", 38 | "@opentelemetry/exporter-prometheus": "^0.37.0", 39 | "@opentelemetry/exporter-zipkin": "^1.11.0", 40 | "@opentelemetry/sdk-metrics": "^1.11.0", 41 | "@opentelemetry/semantic-conventions": "^1.11.0", 42 | "cli-ux": "^5.5.1", 43 | "date-fns": "^2.29.3", 44 | "deepmerge": "^4.2.2", 45 | "dotenv": "^10.0.0", 46 | "nanoid": "^3.1.25", 47 | "ngrok": "^4.2.2", 48 | "node-cron": "^3.0.2", 49 | "node-fetch": "^2.6.6", 50 | "pb-util": "^1.0.2", 51 | "tslib": "^1", 52 | "uuid": "^8.3.2", 53 | "ws": "^8.1.0" 54 | }, 55 | "devDependencies": { 56 | "@commitlint/cli": "^17.0.3", 57 | "@commitlint/config-conventional": "^17.0.3", 58 | "@oclif/dev-cli": "^1.26.0", 59 | "@oclif/test": "^1.2.8", 60 | "@types/chai": "^4", 61 | "@types/mocha": "^5", 62 | "@types/node": "^16.11.10", 63 | "@types/uuid": "^8.3.4", 64 | "@types/ws": "^7.4.7", 65 | "@typescript-eslint/eslint-plugin": "^5.57.1", 66 | "chai": "^4", 67 | "eslint": "^7.23.0", 68 | "eslint-config-google": "^0.14.0", 69 | "eslint-config-oclif": "^3.1.0", 70 | "eslint-config-oclif-typescript": "^0.1.0", 71 | "eslint-config-prettier": "^8.1.0", 72 | "eslint-plugin-no-loops": "^0.3.0", 73 | "eslint-plugin-notice": "^0.9.10", 74 | "eslint-plugin-prettier": "^3.3.1", 75 | "husky": "^8.0.3", 76 | "mocha": "^9.2.2", 77 | "nyc": "^14", 78 | "prettier": "^2.6.2", 79 | "rimraf": "^3.0.2", 80 | "ts-node": "^8.10.2", 81 | "typescript": "^4.4.2" 82 | }, 83 | "files": [ 84 | "/bin", 85 | "/dist", 86 | "/npm-shrinkwrap.json", 87 | "/oclif.manifest.json" 88 | ], 89 | "oclif": { 90 | "bin": "rox" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/cerebro/cerebro.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { EffectsManager } from "./effects" 20 | import { IntentsEngine } from "../intents/types" 21 | import { SGatherStream, VoiceRequest, VoiceResponse } from "@fonoster/voice" 22 | import { CerebroConfig, CerebroStatus } from "./types" 23 | import { sendClientEvent } from "../util" 24 | import { CLIENT_EVENTS } from "../events/types" 25 | import logger, { ulogger, ULogType } from "@fonoster/logger" 26 | import Events from "events" 27 | 28 | export class Cerebro { 29 | voiceResponse: VoiceResponse 30 | cerebroEvents: Events 31 | voiceRequest: VoiceRequest 32 | status: CerebroStatus 33 | activationTimeout: number 34 | activeTimer: NodeJS.Timer 35 | intentsEngine: IntentsEngine 36 | stream: SGatherStream 37 | config: CerebroConfig 38 | lastIntent: any 39 | effects: EffectsManager 40 | constructor(config: CerebroConfig) { 41 | this.voiceResponse = config.voiceResponse 42 | this.voiceRequest = config.voiceRequest 43 | this.cerebroEvents = new Events() 44 | this.status = CerebroStatus.SLEEP 45 | this.activationTimeout = config.activationTimeout || 15000 46 | this.intentsEngine = config.intentsEngine 47 | this.effects = new EffectsManager({ 48 | playbackId: config.voiceConfig.playbackId, 49 | eventsClient: config.eventsClient, 50 | voice: config.voiceResponse, 51 | voiceConfig: config.voiceConfig, 52 | activationIntentId: config.activationIntentId, 53 | transfer: config.transfer 54 | }) 55 | this.config = config 56 | } 57 | 58 | // Subscribe to events 59 | async wake() { 60 | this.status = CerebroStatus.AWAKE_PASSIVE 61 | 62 | this.voiceResponse.on("error", (error: Error) => { 63 | this.cerebroEvents.emit("error", error) 64 | ulogger({ 65 | accessKeyId: this.voiceRequest.accessKeyId, 66 | eventType: ULogType.APP, 67 | level: "error", 68 | message: (error as Error).message 69 | }) 70 | }) 71 | 72 | const speechConfig = { source: "speech,dtmf" } as any 73 | if (this.config.alternativeLanguageCode) { 74 | speechConfig.model = "command_and_search" 75 | speechConfig.alternativeLanguageCodes = [ 76 | this.config.alternativeLanguageCode 77 | ] 78 | } 79 | 80 | this.stream = await this.voiceResponse.sgather(speechConfig as any) 81 | 82 | this.stream.on("transcript", async (data) => { 83 | if (data.isFinal) { 84 | const intent = await this.intentsEngine.findIntent(data.transcript, { 85 | telephony: { 86 | caller_id: this.voiceRequest.callerNumber 87 | } 88 | }) 89 | 90 | logger.verbose("cerebro received new transcription from user", { 91 | text: data.transcript, 92 | ref: intent.ref, 93 | confidence: intent.confidence 94 | }) 95 | 96 | await this.effects.invokeEffects(intent, this.status, async () => { 97 | await this.stopPlayback() 98 | if (this.config.activationIntentId === intent.ref) { 99 | sendClientEvent(this.config.eventsClient, { 100 | eventName: CLIENT_EVENTS.RECOGNIZING 101 | }) 102 | 103 | if (this.status === CerebroStatus.AWAKE_ACTIVE) { 104 | this.resetActiveTimer() 105 | } else { 106 | this.startActiveTimer() 107 | } 108 | } 109 | }) 110 | 111 | // Need to save this to avoid duplicate intents 112 | this.lastIntent = intent 113 | } 114 | }) 115 | } 116 | 117 | // Unsubscribe from events 118 | async sleep() { 119 | logger.verbose("cerebro timeout and is going to sleep") 120 | await this.voiceResponse.closeMediaPipe() 121 | this.stream.close() 122 | this.status = CerebroStatus.SLEEP 123 | } 124 | 125 | startActiveTimer(): void { 126 | this.status = CerebroStatus.AWAKE_ACTIVE 127 | this.activeTimer = setTimeout(() => { 128 | this.status = CerebroStatus.AWAKE_PASSIVE 129 | 130 | sendClientEvent(this.config.eventsClient, { 131 | eventName: CLIENT_EVENTS.RECOGNIZING_FINISHED 132 | }) 133 | 134 | logger.verbose("cerebro changed awake status", { status: this.status }) 135 | }, this.activationTimeout) 136 | logger.verbose("cerebro changed awake status", { status: this.status }) 137 | } 138 | 139 | resetActiveTimer(): void { 140 | logger.verbose("cerebro is reseting awake status") 141 | clearTimeout(this.activeTimer) 142 | this.startActiveTimer() 143 | } 144 | 145 | async stopPlayback() { 146 | const { playbackId } = this.config.voiceConfig 147 | if (playbackId) { 148 | try { 149 | const playbackControl = this.voiceResponse.playback(playbackId) 150 | 151 | logger.verbose("cerebro is stoping playback", { playbackId }) 152 | 153 | await playbackControl.stop() 154 | } catch (e) { 155 | ulogger({ 156 | accessKeyId: this.voiceRequest.accessKeyId, 157 | eventType: ULogType.APP, 158 | level: "error", 159 | message: (e as Error).message 160 | }) 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/cerebro/effects.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { PlaybackControl, VoiceResponse } from "@fonoster/voice" 20 | import { Intent } from "../intents/types" 21 | import { nanoid } from "nanoid" 22 | import { 23 | playBusyAndHangup, 24 | playNoAnswerAndHangup, 25 | playTransfering 26 | } from "./helper" 27 | import { EffectsManagerConfig, CerebroStatus, Effect } from "./types" 28 | import { sendClientEvent } from "../util" 29 | import { CLIENT_EVENTS } from "../events/types" 30 | import logger from "@fonoster/logger" 31 | 32 | export class EffectsManager { 33 | voice: VoiceResponse 34 | config: EffectsManagerConfig 35 | constructor(config: EffectsManagerConfig) { 36 | this.voice = config.voice 37 | this.config = config 38 | } 39 | 40 | async invokeEffects( 41 | intent: Intent, 42 | status: CerebroStatus, 43 | activateCallback: Function 44 | ) { 45 | activateCallback() 46 | if (this.config.activationIntentId === intent.ref) { 47 | logger.verbose("fired activation intent") 48 | return 49 | } else if ( 50 | this.config.activationIntentId && 51 | status != CerebroStatus.AWAKE_ACTIVE 52 | ) { 53 | logger.verbose("received an intent but cerebro is not awake") 54 | // If we have activation intent cerebro needs and active status 55 | // before we can have any effects 56 | return 57 | } 58 | 59 | for (const e of intent.effects) { 60 | logger.verbose("effects running effect", { type: e.type }) 61 | await this.run(e) 62 | } 63 | } 64 | 65 | async run(effect: Effect) { 66 | switch (effect.type) { 67 | case "say": 68 | await this.voice.say( 69 | effect.parameters["response"] as string, 70 | this.config.voiceConfig 71 | ) 72 | break 73 | case "hangup": 74 | await this.voice.hangup() 75 | 76 | sendClientEvent(this.config.eventsClient, { 77 | eventName: CLIENT_EVENTS.HANGUP 78 | }) 79 | 80 | break 81 | case "transfer": 82 | // TODO: Add record effect 83 | await this.transferEffect(this.voice, effect) 84 | break 85 | case "send_data": 86 | // Only send if client support events 87 | sendClientEvent(this.config.eventsClient, { 88 | eventName: CLIENT_EVENTS.RECOGNIZING_FINISHED 89 | }) 90 | sendClientEvent(this.config.eventsClient, { 91 | eventName: CLIENT_EVENTS.INTENT, 92 | intent: effect.parameters as any 93 | }) 94 | 95 | break 96 | default: 97 | throw new Error(`effects received unknown effect ${effect.type}`) 98 | } 99 | } 100 | 101 | async transferEffect(voice: VoiceResponse, effect: Effect) { 102 | await this.voice.closeMediaPipe() 103 | const stream = await this.voice.dial( 104 | effect.parameters["destination"] as string 105 | ) 106 | const playbackId: string = nanoid() 107 | const control: PlaybackControl = this.voice.playback(playbackId) 108 | 109 | let stay = true 110 | const moveForward = async () => { 111 | stay = false 112 | await control.stop() 113 | } 114 | 115 | stream.on("answer", () => { 116 | moveForward() 117 | }) 118 | 119 | stream.on("busy", async () => { 120 | await moveForward() 121 | await playBusyAndHangup(this.voice, playbackId, this.config) 122 | }) 123 | 124 | stream.on("noanswer", async () => { 125 | await moveForward() 126 | await playNoAnswerAndHangup(this.voice, playbackId, this.config) 127 | }) 128 | 129 | while (stay) { 130 | await playTransfering(this.voice, playbackId, this.config) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/cerebro/helper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { VoiceResponse } from "@fonoster/voice" 20 | import { SayOptions } from "@fonoster/voice/dist/say/types" 21 | import { EffectsManagerConfig } from "./types" 22 | 23 | const playOrSay = async (param: { 24 | voice: VoiceResponse 25 | voiceConfig: Record 26 | playbackId: string 27 | media?: string 28 | message?: string 29 | }) => { 30 | if (param.media) { 31 | await param.voice.play("sound:" + param.media, { 32 | playbackId: param.playbackId 33 | }) 34 | } 35 | if (param.message) { 36 | if (param.voiceConfig) { 37 | param.voiceConfig.playbackId = param.playbackId 38 | } else { 39 | param.voiceConfig = { playbackId: param.playbackId } 40 | } 41 | await param.voice.say(param.message, param.voiceConfig as SayOptions) 42 | } 43 | } 44 | 45 | export const playTransfering = async ( 46 | voice: VoiceResponse, 47 | playbackId: string, 48 | config: EffectsManagerConfig 49 | ) => 50 | await playOrSay({ 51 | voice, 52 | voiceConfig: config.voiceConfig, 53 | playbackId, 54 | media: config.transfer?.media, 55 | message: config.transfer?.message 56 | }) 57 | 58 | export const playBusyAndHangup = async ( 59 | voice: VoiceResponse, 60 | playbackId: string, 61 | config: EffectsManagerConfig 62 | ) => 63 | await playOrSay({ 64 | voice, 65 | voiceConfig: config.voiceConfig, 66 | playbackId, 67 | media: config.transfer?.mediaBusy, 68 | message: config.transfer?.messageBusy 69 | }) 70 | 71 | export const playNoAnswerAndHangup = async ( 72 | voice: VoiceResponse, 73 | playbackId: string, 74 | config: EffectsManagerConfig 75 | ) => 76 | await playOrSay({ 77 | voice, 78 | voiceConfig: config.voiceConfig, 79 | playbackId, 80 | media: config.transfer?.mediaNoAnswer, 81 | message: config.transfer?.messageNoAnswer 82 | }) 83 | -------------------------------------------------------------------------------- /src/cerebro/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | export * from "./cerebro" 20 | export * from "./effects" 21 | -------------------------------------------------------------------------------- /src/cerebro/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { VoiceResponse } from "@fonoster/voice" 20 | import { VoiceRequest } from "@fonoster/voice/dist/types" 21 | import { EventsClient } from "../events/emitter" 22 | import { IntentsEngine } from "../intents/types" 23 | 24 | export enum CerebroStatus { 25 | SLEEP, 26 | AWAKE_ACTIVE, 27 | AWAKE_PASSIVE 28 | } 29 | 30 | export interface CerebroConfig { 31 | voiceRequest: VoiceRequest 32 | voiceResponse: VoiceResponse 33 | activationTimeout?: number 34 | activationIntentId?: string 35 | intentsEngine: IntentsEngine 36 | voiceConfig: Record 37 | eventsClient: EventsClient | null 38 | transfer?: Transfer 39 | alternativeLanguageCode?: string 40 | } 41 | 42 | export interface Transfer { 43 | media?: string 44 | mediaNoAnswer?: string 45 | mediaBusy?: string 46 | message?: string 47 | messageNoAnswer?: string 48 | messageBusy?: string 49 | } 50 | 51 | export interface EffectsManagerConfig { 52 | eventsClient: EventsClient | null 53 | voice: VoiceResponse 54 | voiceConfig: Record 55 | activationIntentId?: string 56 | playbackId: string 57 | transfer?: Transfer 58 | } 59 | 60 | export interface Effect { 61 | type: "say" | "hangup" | "send_data" | "transfer" 62 | parameters: Record 63 | } 64 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License") 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { ServerConfig } from "./types" 20 | import { getEnvOrBool, getEnvOrDefault, removeEmpty } from "./util" 21 | import dotenv from "dotenv" 22 | import os from "os" 23 | 24 | export const getConfigFromEnv = (): ServerConfig => { 25 | // Load parameters from the environment 26 | dotenv.config() 27 | return removeEmpty({ 28 | eventsServerEnabled: process.env.EVENTS_SERVER_ENABLED === "true", 29 | defaultLanguageCode: process.env.DEFAULT_LANGUAGE_CODE, 30 | otlExporterJaegerUrl: process.env.OTL_EXPORTER_JAEGER_URL, 31 | otlExporterZipkinUrl: process.env.OTL_EXPORTER_ZIPKIN_URL, 32 | otlExporterPrometheusEndpoint: process.env.OTL_EXPORTER_PROMETHEUS_ENDPOINT, 33 | otlExporterPrometheusPort: getEnvOrDefault( 34 | "OTL_EXPORTER_PROMETHEUS_PORT", 35 | 9090 36 | ), 37 | otlExporterGCPEnabled: getEnvOrBool("OTL_EXPORTER_GCP_ENABLED"), 38 | fileRetentionPolicyEnabled: getEnvOrBool("FILE_RETENTION_POLICY_ENABLED"), 39 | fileRetentionPolicyDirectory: 40 | process.env.FILE_RETENTION_POLICY_DIRECTORY || os.tmpdir(), 41 | fileRetentionPolicyCronExpression: 42 | process.env.FILE_RETENTION_POLICY_CRON_EXPRESSION || "0 0 * * *", 43 | fileRetentionPolicyMaxAge: getEnvOrDefault( 44 | "FILE_RETENTION_POLICY_MAX_AGE", 45 | 24 46 | ), 47 | fileRetentionPolicyExtension: 48 | process.env.FILE_RETENTION_POLICY_EXTENSION || ".sln24" 49 | }) as ServerConfig 50 | } 51 | 52 | export const getConfigFromFlags = (flags: any): ServerConfig => 53 | removeEmpty({ 54 | eventsServerEnabled: flags["events-server-enabled"], 55 | defaultLanguageCode: flags["default-language-code"], 56 | otlExporterJaegerUrl: flags["otl-exporter-jaeger-url"], 57 | otlExporterZipkinUrl: flags["otl-exporter-zipkin-url"], 58 | otlExporterPrometheusEndpoint: flags["otl-exporter-promethus-endpoint"], 59 | otlExporterPrometheusPort: flags["otl-exporter-promethus-port"], 60 | otlExporterGCPEnabled: flags["otl-exporter-gcp-enabled"], 61 | fileRetentionPolicyEnabled: flags["file-retention-policy-enabled"], 62 | fileRetentionPolicyDirectory: flags["file-retention-policy-directory"], 63 | fileRetentionPolicyCronExpression: 64 | flags["file-retention-policy-cron-expression"], 65 | fileRetentionPolicyMaxAge: flags["file-retention-policy-max-age"], 66 | fileRetentionPolicyExtension: flags["file-retention-policy-extension"] 67 | }) as ServerConfig 68 | -------------------------------------------------------------------------------- /src/events/emitter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { EventEmitter, ClientEvent } from "./types" 20 | import WebSocket = require("ws") 21 | 22 | export class EventsClient implements EventEmitter { 23 | ws: WebSocket 24 | constructor(ws: WebSocket) { 25 | this.ws = ws 26 | } 27 | 28 | send(event: ClientEvent) { 29 | this.ws.send(JSON.stringify(event)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/events/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { EventsClient } from "./emitter" 20 | import WebSocket = require("ws") 21 | import logger from "@fonoster/logger" 22 | 23 | // Events server 24 | export class EventsServer { 25 | clientConnections: Map 26 | wss: WebSocket.Server 27 | port: number 28 | 29 | constructor(clientConnections: Map, port = 3001) { 30 | this.port = port 31 | this.wss = new WebSocket.Server({ port }) 32 | this.clientConnections = clientConnections 33 | } 34 | 35 | start() { 36 | this.wss.on("connection", (ws) => { 37 | logger.verbose("received a new client connection") 38 | ws.on("message", (data) => { 39 | // Once we receive the first and only message from client we 40 | // save the client in the clientConnections map 41 | const clientId = JSON.parse(data.toString()).clientId 42 | this.clientConnections.set(clientId, ws) 43 | logger.verbose("added clientId to list of connections", { clientId }) 44 | }) 45 | 46 | ws.send( 47 | JSON.stringify({ 48 | name: "connected", 49 | payload: {} 50 | }) 51 | ) 52 | }) 53 | 54 | logger.verbose("starting events server", { port: this.port }) 55 | } 56 | 57 | getConnection(clientId: string): EventsClient | null { 58 | const connection = this.clientConnections.get(clientId) 59 | if (!connection) { 60 | return null 61 | } 62 | return new EventsClient(connection) 63 | } 64 | 65 | removeConnection(clientId: string): void { 66 | this.clientConnections.delete(clientId) 67 | } 68 | } 69 | 70 | // Starting events server 71 | export const eventsServer = new EventsServer(new Map()) 72 | -------------------------------------------------------------------------------- /src/events/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | export enum CLIENT_EVENTS { 20 | RECOGNIZING = "RECOGNIZING", 21 | ANSWERED = "ANSWERED", 22 | RECOGNIZING_FINISHED = "RECOGNIZING_FINISHED", 23 | INTENT = "INTENT", 24 | HANGUP = "HANGUP" 25 | } 26 | 27 | export interface EventEmitter { 28 | send(payload?: ClientEvent): void 29 | } 30 | 31 | export interface Intent { 32 | icon?: string 33 | title: string 34 | description: string 35 | transcript: string 36 | } 37 | 38 | export interface ClientEvent { 39 | eventName: CLIENT_EVENTS 40 | intent?: Intent 41 | } 42 | -------------------------------------------------------------------------------- /src/file-retention/cron.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster 4 | * 5 | * This file is part of nodejs-voiceapp 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { ServerConfig } from "../types" 20 | import { runFileRetentionPolicy } from "./task" 21 | import cron from "node-cron" 22 | import logger from "@fonoster/logger" 23 | 24 | export const startFileRetentionPolicy = (config: ServerConfig) => { 25 | if (config.fileRetentionPolicyEnabled) { 26 | logger.info("file retention policy enabled") 27 | 28 | cron.schedule(config.fileRetentionPolicyCronExpression, () => 29 | runFileRetentionPolicy({ 30 | filesDirectory: config.fileRetentionPolicyDirectory, 31 | maxFileAge: config.fileRetentionPolicyMaxAge, 32 | fileExtension: config.fileRetentionPolicyExtension 33 | }) 34 | ) 35 | 36 | return 37 | } 38 | 39 | logger.info( 40 | "file retention policy is disabled, all files will be kept forever in the server" 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/file-retention/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster 4 | * 5 | * This file is part of nodejs-voiceapp 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | export * from "./cron" 20 | -------------------------------------------------------------------------------- /src/file-retention/task.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster 4 | * 5 | * This file is part of nodejs-voiceapp 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { differenceInHours } from "date-fns" 20 | import logger from "@fonoster/logger" 21 | import fs from "fs" 22 | import path from "path" 23 | 24 | export type FileRetentionPolicyConfig = { 25 | filesDirectory: string 26 | maxFileAge: number 27 | fileExtension: string 28 | } 29 | 30 | export const runFileRetentionPolicy = (config: FileRetentionPolicyConfig) => { 31 | logger.verbose( 32 | "running file retention policy in directory: " + config.filesDirectory 33 | ) 34 | 35 | fs.readdir(config.filesDirectory, (err, files) => { 36 | if (err) throw err 37 | 38 | const ttsFiles = files.filter( 39 | (file) => path.extname(file) === config.fileExtension 40 | ) 41 | 42 | logger.verbose("found " + ttsFiles.length + " files to be deleted") 43 | 44 | for (const file of ttsFiles) { 45 | const filePath = path.join(config.filesDirectory, file) 46 | 47 | fs.stat(filePath, (err, stats) => { 48 | if (err) throw err 49 | 50 | const diff = differenceInHours(new Date(), stats.atime) 51 | 52 | if (diff > config.maxFileAge) { 53 | logger.verbose( 54 | "file " + file + " was last accessed " + diff + " hours ago" 55 | ) 56 | 57 | logger.verbose("deleting file " + file) 58 | 59 | fs.unlink(filePath, (err) => { 60 | if (err) throw err 61 | }) 62 | } 63 | }) 64 | } 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License") 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { Command, flags } from "@oclif/command" 20 | import { ServerConfig } from "./types" 21 | import { voice } from "./voice" 22 | import { getConfigFromEnv, getConfigFromFlags } from "./config" 23 | import { startFileRetentionPolicy } from "./file-retention" 24 | import logger from "@fonoster/logger" 25 | import ngrok from "ngrok" 26 | import merge from "deepmerge" 27 | import process from "process" 28 | 29 | class Rox extends Command { 30 | static description = "starts a new Rox AI instance" 31 | static flags = { 32 | version: flags.version({ char: "v", description: "show rox version" }), 33 | help: flags.help({ char: "h", description: "show this help" }), 34 | "with-ngrok": flags.boolean({ 35 | char: "g", 36 | description: "open a tunnel with ngrok" 37 | }), 38 | "events-server-enabled": flags.boolean({ 39 | char: "e", 40 | description: "starts events socket" 41 | }), 42 | "ngrok-authtoken": flags.string({ 43 | description: "ngrok authentication token" 44 | }), 45 | "default-language-code": flags.string({ description: "default language" }), 46 | "otl-exporter-jaeger-url": flags.string({ 47 | description: "if set will send telemetry to Jaeger" 48 | }), 49 | "otl-exporter-zipkin-url": flags.string({ 50 | description: "if set will send telemetry to Zipkin" 51 | }), 52 | "otl-exporter-prometheus-port": flags.string({ 53 | description: "sets Prometheus port. Defaults to 9090" 54 | }), 55 | "otl-exporter-prometheus-endpoint": flags.string({ 56 | description: 'sets Prometheus endpoint. Defaults to "/metrics"' 57 | }), 58 | "otl-exporter-gcp-enabled": flags.boolean({ 59 | char: "g", 60 | description: "if set it will send telemetry to GCP" 61 | }), 62 | "google-config-file": flags.string({ 63 | description: "config file with google credentials" 64 | }), 65 | "file-retention-policy-enabled": flags.boolean({ 66 | description: "enable file retention policy" 67 | }), 68 | "file-retention-policy-directory": flags.string({ 69 | description: "directory where the file retention policy will be executed" 70 | }), 71 | "file-retention-policy-cron-expression": flags.string({ 72 | description: "cron expression to run the file retention policy" 73 | }), 74 | "file-retention-policy-max-age": flags.integer({ 75 | description: "max age of files to be deleted in hours" 76 | }) 77 | } 78 | 79 | async run() { 80 | const { flags } = this.parse(Rox) 81 | const config = merge.all([ 82 | { 83 | defaultLanguageCode: "en-US", 84 | intentsEnginePlatform: "PLATFORM_UNSPECIFIED" 85 | }, 86 | getConfigFromEnv(), 87 | getConfigFromFlags(flags) 88 | ]) as ServerConfig 89 | 90 | process.on("uncaughtException", (error, origin) => { 91 | const isPortInUse = error && error["code"] === "EADDRINUSE" 92 | 93 | if (isPortInUse) { 94 | logger.error( 95 | "Port 3000 is already in use. Please stop the process that is using it." 96 | ) 97 | 98 | process.exit(1) 99 | } 100 | 101 | logger.error("----- Uncaught exception -----") 102 | logger.error(error) 103 | logger.error("----- Exception origin -----") 104 | logger.error(origin) 105 | }) 106 | 107 | process.on("unhandledRejection", (reason, promise) => { 108 | logger.error("----- Unhandled Rejection at -----") 109 | logger.error(promise) 110 | logger.error("----- Reason -----") 111 | logger.error(reason) 112 | }) 113 | 114 | voice(config) 115 | 116 | startFileRetentionPolicy(config) 117 | 118 | if (flags["with-ngrok"]) { 119 | try { 120 | const ngrokConfig = flags["ngrok-authtoken"] 121 | ? { addr: 3000, authtoken: flags["ngrok-authtoken"] } 122 | : { addr: "localhost:3000" } 123 | const url = await ngrok.connect(ngrokConfig) 124 | logger.info("application webhook => " + url) 125 | } catch (e) { 126 | logger.error("failed to start ngrok tunnel") 127 | process.exit(1) 128 | } 129 | } 130 | } 131 | } 132 | 133 | export = Rox 134 | -------------------------------------------------------------------------------- /src/intents/df_utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { Effect } from "../cerebro/types" 20 | 21 | function deserializePayload(object: Record): any { 22 | const outputMessage = Array.isArray(object) ? [] : {} 23 | Object.entries(object).forEach(([key, value]) => { 24 | if (value.kind == "structValue") { 25 | outputMessage[key] = deserializePayload(value.structValue.fields) 26 | } else if (value.kind == "listValue") { 27 | outputMessage[key] = deserializePayload(value.listValue.values) 28 | } else if (value.kind == "stringValue") { 29 | outputMessage[key] = value.stringValue 30 | } else if (value.kind == "boolValue") { 31 | outputMessage[key] = value.boolValue 32 | } else { 33 | outputMessage[key] = value 34 | } 35 | }) 36 | return outputMessage as any 37 | } 38 | 39 | export function getRamdomValue(values: Record[]) { 40 | return values[Math.floor(Math.random() * values.length)] 41 | } 42 | 43 | export function transformPayloadToEffect(payload: Record): Effect { 44 | const o = deserializePayload(payload.fields) 45 | const parameters = 46 | o.effect === "say" 47 | ? { response: getRamdomValue(o.parameters.responses) } 48 | : o.parameters 49 | return { 50 | type: o.effect, 51 | parameters 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/intents/dialogflow_cx.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import logger from "@fonoster/logger" 20 | import dialogflow, { SessionsClient } from "@google-cloud/dialogflow-cx" 21 | import { DialogFlowCXConfig, IntentsEngine, Intent } from "./types" 22 | import { transformPayloadToEffect } from "./df_utils" 23 | import { Effect } from "../cerebro/types" 24 | import uuid = require("uuid") 25 | 26 | export default class DialogFlowCX implements IntentsEngine { 27 | sessionClient: SessionsClient 28 | sessionPath: any 29 | config: DialogFlowCXConfig 30 | projectId: string 31 | location: string 32 | agent: string 33 | sessionId: string 34 | constructor(config: DialogFlowCXConfig) { 35 | const sessionId = uuid.v4() 36 | this.projectId = config.projectId 37 | this.location = config.location 38 | this.config = config 39 | this.sessionId = sessionId 40 | this.agent = config.agent 41 | // Create a new session 42 | this.sessionClient = new dialogflow.SessionsClient({ 43 | apiEndpoint: `${config.location}-dialogflow.googleapis.com`, 44 | credentials: config.credentials 45 | }) 46 | logger.verbose("created new dialogflow/cx session", { 47 | projectId: this.projectId, 48 | sessionId: this.sessionId 49 | }) 50 | } 51 | 52 | setProjectId(id: string) { 53 | this.projectId = id 54 | } 55 | 56 | async findIntent(txt: string): Promise { 57 | const sessionPath = this.sessionClient.projectLocationAgentSessionPath( 58 | this.projectId, 59 | this.location, 60 | this.agent, 61 | this.sessionId 62 | ) 63 | 64 | const request = { 65 | session: sessionPath, 66 | queryInput: { 67 | text: { 68 | text: txt 69 | }, 70 | languageCode: this.config.languageCode 71 | } 72 | } 73 | 74 | const responses = await this.sessionClient.detectIntent(request) 75 | 76 | logger.silly("got speech from api", { text: JSON.stringify(responses[0]) }) 77 | 78 | if ( 79 | !responses || 80 | !responses[0].queryResult || 81 | !responses[0].queryResult.responseMessages 82 | ) { 83 | throw new Error("got unexpect null intent") 84 | } 85 | 86 | const effects: Effect[] = this.getEffects( 87 | responses[0].queryResult.responseMessages as Record[] 88 | ) 89 | 90 | const ref = responses[0].queryResult.intent 91 | ? responses[0].queryResult.intent.displayName || "unknown" 92 | : "unknown" 93 | 94 | return { 95 | ref, 96 | effects, 97 | confidence: responses[0].queryResult.intentDetectionConfidence || 0, 98 | allRequiredParamsPresent: responses[0].queryResult.text ? true : false 99 | } 100 | } 101 | 102 | private getEffects(responseMessages: Record[]): Effect[] { 103 | const effects: Effect[] = [] 104 | for (const r of responseMessages) { 105 | if (r.message === "text") { 106 | effects.push({ 107 | type: "say", 108 | parameters: { 109 | response: r.text.text[0] 110 | } 111 | }) 112 | continue 113 | } else if (r.payload) { 114 | effects.push(transformPayloadToEffect(r.payload)) 115 | } 116 | } 117 | return effects 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/intents/dialogflow_es.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import * as dialogflow from "@google-cloud/dialogflow" 20 | import { IntentsEngine, Intent, DialogFlowESConfig } from "./types" 21 | import { transformPayloadToEffect } from "./df_utils" 22 | import { struct } from "pb-util" 23 | import { Effect } from "../cerebro/types" 24 | import logger from "@fonoster/logger" 25 | import uuid = require("uuid") 26 | 27 | export default class DialogFlow implements IntentsEngine { 28 | sessionClient: dialogflow.v2beta1.SessionsClient 29 | sessionPath: any 30 | config: DialogFlowESConfig 31 | sessionId: string 32 | projectId: string 33 | constructor(config: DialogFlowESConfig) { 34 | this.sessionId = uuid.v4() 35 | this.config = config 36 | this.projectId = config.projectId 37 | // Create a new session 38 | this.sessionClient = new dialogflow.v2beta1.SessionsClient({ 39 | credentials: config.credentials 40 | }) 41 | logger.verbose("created new dialogflow/es session", { 42 | projectId: this.projectId, 43 | sessionId: this.sessionId 44 | }) 45 | } 46 | 47 | setProjectId(projectId: string) { 48 | this.projectId = projectId 49 | } 50 | 51 | async findIntentWithEvent(name: string, payload?: Record) { 52 | const request = { 53 | queryInput: { 54 | event: { 55 | name: name.toUpperCase(), 56 | languageCode: this.config.languageCode 57 | } 58 | } 59 | } 60 | 61 | return this.detect(request, payload) 62 | } 63 | 64 | async findIntent( 65 | txt: string, 66 | payload?: Record 67 | ): Promise { 68 | const request = { 69 | queryParams: {}, 70 | queryInput: { 71 | text: { 72 | text: txt, 73 | languageCode: this.config.languageCode 74 | } 75 | } 76 | } 77 | 78 | return this.detect(request, payload) 79 | } 80 | 81 | private async detect( 82 | request: Record, 83 | payload?: Record 84 | ): Promise { 85 | const sessionPath = this.sessionClient.projectAgentSessionPath( 86 | this.projectId, 87 | this.sessionId 88 | ) 89 | 90 | request.session = sessionPath 91 | 92 | if (payload) { 93 | request.queryParams = { 94 | payload: struct.encode(payload as any) 95 | } 96 | } 97 | 98 | const responses = await this.sessionClient.detectIntent(request) 99 | 100 | logger.silly("got speech from api", { text: JSON.stringify(responses[0]) }) 101 | 102 | if ( 103 | !responses || 104 | !responses[0].queryResult || 105 | !responses[0].queryResult.intent 106 | ) { 107 | throw new Error("got unexpect null intent") 108 | } 109 | 110 | let effects: Effect[] = [] 111 | 112 | if (responses[0].queryResult.fulfillmentMessages) { 113 | const messages = responses[0].queryResult.fulfillmentMessages.filter( 114 | (f) => f.platform === this.config.platform 115 | ) 116 | effects = this.getEffects(messages as Record[]) 117 | } else if (responses[0].queryResult.fulfillmentText) { 118 | effects = [ 119 | { 120 | type: "say", 121 | parameters: { 122 | response: responses[0].queryResult.fulfillmentText 123 | } 124 | } 125 | ] 126 | } 127 | 128 | return { 129 | ref: responses[0].queryResult.intent.displayName || "unknown", 130 | effects, 131 | confidence: responses[0].queryResult.intentDetectionConfidence || 0, 132 | allRequiredParamsPresent: responses[0].queryResult 133 | .allRequiredParamsPresent 134 | ? true 135 | : false 136 | } 137 | } 138 | 139 | private getEffects(fulfillmentMessages: Record[]): Effect[] { 140 | const effects: Effect[] = [] 141 | for (const f of fulfillmentMessages) { 142 | if (f.payload) { 143 | effects.push(transformPayloadToEffect(f.payload)) 144 | } else if (f.telephonySynthesizeSpeech) { 145 | effects.push({ 146 | type: "say", 147 | parameters: { 148 | response: 149 | f.telephonySynthesizeSpeech.text || 150 | f.telephonySynthesizeSpeech.ssml 151 | } 152 | }) 153 | } else if (f.telephonyTransferCall) { 154 | effects.push({ 155 | type: "transfer", 156 | parameters: { 157 | destination: f.telephonyTransferCall.phoneNumber 158 | } 159 | }) 160 | } else if (f.text) { 161 | effects.push({ 162 | type: "say", 163 | parameters: { 164 | response: f.text.text[0] 165 | } 166 | }) 167 | } 168 | } 169 | return effects 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/intents/engines.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { App } from "@fonoster/apps/dist/client/types" 20 | import { IntentsEngine } from "./types" 21 | import DialogFlowCX from "./dialogflow_cx" 22 | import DialogFlowES from "./dialogflow_es" 23 | 24 | export function getIntentsEngine(app: App) { 25 | return function getEngine( 26 | credentials: Record 27 | ): IntentsEngine { 28 | const platform = app.intentsEngineConfig.emulateTelephonyPlatform 29 | ? "TELEPHONY" 30 | : "PLATFORM_UNSPECIFIED" 31 | 32 | if ("location" in app.intentsEngineConfig) { 33 | return new DialogFlowCX({ 34 | credentials, 35 | projectId: app.intentsEngineConfig.projectId, 36 | agent: app.intentsEngineConfig.agent, 37 | location: app.intentsEngineConfig.location, 38 | platform, 39 | languageCode: "en-US" 40 | }) 41 | } 42 | 43 | return new DialogFlowES({ 44 | credentials, 45 | projectId: app.intentsEngineConfig.projectId, 46 | platform, 47 | languageCode: "en-US" 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/intents/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { Effect } from "../cerebro/types" 20 | 21 | export interface Intent { 22 | ref: string 23 | effects: Effect[] 24 | confidence: number 25 | allRequiredParamsPresent: boolean 26 | } 27 | 28 | export interface IntentsEngine { 29 | setProjectId: (id: string) => void 30 | findIntent: ( 31 | text: string, 32 | payload?: Record 33 | ) => Promise 34 | findIntentWithEvent?: ( 35 | name: string, 36 | payload?: Record 37 | ) => Promise 38 | } 39 | 40 | export interface DialogFlowESConfig { 41 | projectId: string 42 | languageCode: string 43 | platform: string 44 | credentials: Record 45 | } 46 | 47 | export interface DialogFlowCXConfig { 48 | projectId: string 49 | languageCode: string 50 | location: string 51 | agent: string 52 | platform: string 53 | credentials: Record 54 | } 55 | -------------------------------------------------------------------------------- /src/telemetry/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License") 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { JaegerExporter } from "@opentelemetry/exporter-jaeger" 20 | import { ZipkinExporter } from "@opentelemetry/exporter-zipkin" 21 | import { TraceExporter } from "@google-cloud/opentelemetry-cloud-trace-exporter" 22 | import { PrometheusExporter } from "@opentelemetry/exporter-prometheus" 23 | import { MeterProvider } from "@opentelemetry/sdk-metrics" 24 | import logger from "@fonoster/logger" 25 | 26 | interface RoxTelmetryConfig { 27 | jaegerUrl?: string 28 | zipkinUrl?: string 29 | gcpEnabled?: boolean 30 | gcpKeyfile?: string 31 | } 32 | 33 | interface RoxMetricConfig { 34 | prometheusPort?: number 35 | prometheusEndpoint?: string 36 | } 37 | 38 | export function getSpanExporters(config: RoxTelmetryConfig): Array { 39 | const exporters: any = [] 40 | 41 | if (config.gcpEnabled) { 42 | exporters.push({ 43 | exporter: TraceExporter, 44 | config: { 45 | keyFile: config.gcpKeyfile 46 | } 47 | }) 48 | } 49 | 50 | if (config.jaegerUrl) { 51 | exporters.push({ 52 | exporter: JaegerExporter, 53 | config: { 54 | endpoint: config.jaegerUrl 55 | } 56 | }) 57 | } 58 | 59 | if (config.zipkinUrl) { 60 | exporters.push({ 61 | exporter: ZipkinExporter, 62 | config: { 63 | url: config.zipkinUrl 64 | } 65 | }) 66 | } 67 | 68 | return exporters 69 | } 70 | 71 | export function getMeterProvider( 72 | config: RoxMetricConfig 73 | ): MeterProvider | undefined { 74 | config.prometheusPort = 75 | config.prometheusPort || PrometheusExporter.DEFAULT_OPTIONS.port 76 | config.prometheusEndpoint = 77 | config.prometheusEndpoint || PrometheusExporter.DEFAULT_OPTIONS.endpoint 78 | 79 | const options = { 80 | port: config.prometheusPort, 81 | endpoint: config.prometheusEndpoint, 82 | startServer: true 83 | } 84 | 85 | const exporter = new PrometheusExporter(options, () => { 86 | logger.info("starting prometheus scrape process", { 87 | endpoint: `http://localhost:${config.prometheusPort}${config.prometheusEndpoint}` 88 | }) 89 | }) 90 | 91 | const provider = new MeterProvider() 92 | 93 | provider.addMetricReader(exporter) 94 | 95 | return provider 96 | } 97 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License") 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | export interface ServerConfig { 20 | defaultLanguageCode: string 21 | googleConfigFile: string 22 | otlExporterJaegerUrl?: string 23 | otlExporterZipkinUrl?: string 24 | otlExporterPrometheusEndpoint?: string 25 | otlExporterPrometheusPort?: number 26 | otlExporterGCPEnabled?: boolean 27 | eventsServerEnabled: boolean 28 | /** 29 | * Enable file retention policy 30 | * 31 | * @default true 32 | */ 33 | fileRetentionPolicyEnabled?: boolean 34 | /** 35 | * Directory where the file retention policy will be executed 36 | * 37 | * @default os.tmpdir() 38 | */ 39 | fileRetentionPolicyDirectory: string 40 | /** 41 | * Cron expression to run the file retention policy 42 | * 43 | * @default 0 0 * * * 44 | * @see https://crontab.guru/#0_0_*_*_* 45 | */ 46 | fileRetentionPolicyCronExpression: string 47 | /** 48 | * Max age in hours to keep files 49 | * 50 | * @default 24 51 | */ 52 | fileRetentionPolicyMaxAge: number 53 | /** 54 | * File extension to be deleted 55 | * 56 | * @default .sln24 57 | */ 58 | fileRetentionPolicyExtension: string 59 | } 60 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/rox 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License") 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | import { EventsClient } from "./events/emitter" 20 | import { ClientEvent } from "./events/types" 21 | 22 | export const getEnvOrDefault = (envName: string, def: number) => 23 | process.env[envName] ? parseInt(process.env[envName] || "") : def 24 | 25 | export const getEnvOrBool = (envName: string) => 26 | process.env[envName] 27 | ? (process.env[envName] || "false").toLowerCase() === "true" 28 | : false 29 | 30 | export const removeEmpty = (obj) => { 31 | const newObj = {} 32 | Object.keys(obj).forEach((key) => { 33 | if (obj[key] === Object(obj[key])) newObj[key] = removeEmpty(obj[key]) 34 | else if (obj[key] !== undefined) newObj[key] = obj[key] 35 | }) 36 | return newObj 37 | } 38 | 39 | export const sendClientEvent = ( 40 | eventsClient: EventsClient | null, 41 | event: ClientEvent 42 | ) => { 43 | if (eventsClient) { 44 | eventsClient.send(event) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/voice.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 4 | * http://github.com/fonoster/rox 5 | * 6 | * This file is part of Rox AI 7 | * 8 | * Licensed under the MIT License (the "License"); 9 | * you may not use this file except in compliance with 10 | * the License. You may obtain a copy of the License at 11 | * 12 | * https://opensource.org/licenses/MIT 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | import { VoiceRequest, VoiceResponse, VoiceServer } from "@fonoster/voice" 21 | import { Cerebro } from "./cerebro" 22 | import { eventsServer } from "./events/server" 23 | import { nanoid } from "nanoid" 24 | import { getSpanExporters, getMeterProvider } from "./telemetry" 25 | import { getIntentsEngine } from "./intents/engines" 26 | import { ServerConfig } from "./types" 27 | import { sendClientEvent } from "./util" 28 | import { CLIENT_EVENTS } from "./events/types" 29 | import logger, { ulogger, ULogType } from "@fonoster/logger" 30 | import Apps from "@fonoster/apps" 31 | import Secrets from "@fonoster/secrets" 32 | import GoogleTTS from "@fonoster/googletts" 33 | import GoogleASR from "@fonoster/googleasr" 34 | 35 | const { version } = require("../package.json") 36 | 37 | export function voice(config: ServerConfig) { 38 | logger.info(`rox ai ${version}`) 39 | const meterProvider = getMeterProvider({ 40 | prometheusPort: config.otlExporterPrometheusPort, 41 | prometheusEndpoint: config.otlExporterPrometheusEndpoint 42 | }) 43 | const meter = meterProvider?.getMeter("rox_metrics") 44 | const callCounter = meter?.createCounter("call_counter") 45 | const voiceServer = new VoiceServer({ 46 | otlSpanExporters: getSpanExporters({ 47 | jaegerUrl: config.otlExporterJaegerUrl, 48 | zipkinUrl: config.otlExporterZipkinUrl, 49 | gcpEnabled: config.otlExporterGCPEnabled, 50 | gcpKeyfile: config.googleConfigFile 51 | }) 52 | }) 53 | 54 | if (config.eventsServerEnabled) eventsServer.start() 55 | 56 | logger.verbose("events server enabled = " + config.eventsServerEnabled) 57 | 58 | voiceServer.listen( 59 | async (voiceRequest: VoiceRequest, voiceResponse: VoiceResponse) => { 60 | logger.verbose(`new request [sessionId: ${voiceRequest.sessionId}]`, { 61 | voiceRequest 62 | }) 63 | 64 | // Sending metrics out to Prometheus 65 | callCounter?.add(1) 66 | 67 | try { 68 | if (!voiceRequest.appRef) 69 | throw new Error("invalid voice request: missing appRef") 70 | // If set, we overwrite the configuration with the values obtain from the webhook 71 | const serviceCredentials = { 72 | accessKeyId: voiceRequest.accessKeyId, 73 | accessKeySecret: voiceRequest.sessionToken 74 | } 75 | const apps = new Apps(serviceCredentials) 76 | const secrets = new Secrets(serviceCredentials) 77 | const app = await apps.getApp(voiceRequest.appRef) 78 | 79 | logger.verbose(`requested app [ref: ${app.ref}]`, { app }) 80 | 81 | const ieSecret = await secrets.getSecret( 82 | app.intentsEngineConfig.secretName 83 | ) 84 | const intentsEngine = getIntentsEngine(app)(JSON.parse(ieSecret.secret)) 85 | intentsEngine?.setProjectId(app.intentsEngineConfig.projectId) 86 | 87 | const voiceConfig = { 88 | name: app.speechConfig.voice, 89 | playbackId: nanoid() 90 | } 91 | 92 | const speechSecret = await secrets.getSecret( 93 | app.speechConfig.secretName 94 | ) 95 | const speechCredentials = { 96 | private_key: JSON.parse(speechSecret.secret).private_key, 97 | client_email: JSON.parse(speechSecret.secret).client_email 98 | } 99 | 100 | voiceResponse.use( 101 | new GoogleTTS({ 102 | credentials: speechCredentials, 103 | languageCode: config.defaultLanguageCode, 104 | path: config.fileRetentionPolicyDirectory 105 | } as any) 106 | ) 107 | 108 | voiceResponse.use( 109 | new GoogleASR({ 110 | credentials: speechCredentials, 111 | languageCode: config.defaultLanguageCode 112 | } as any) 113 | ) 114 | 115 | await voiceResponse.answer() 116 | 117 | const eventsClient = 118 | app.enableEvents && config.eventsServerEnabled 119 | ? eventsServer.getConnection(voiceRequest.callerNumber) 120 | : null 121 | 122 | sendClientEvent(eventsClient, { 123 | eventName: CLIENT_EVENTS.ANSWERED 124 | }) 125 | 126 | if (app.initialDtmf) await voiceResponse.dtmf({ dtmf: app.initialDtmf }) 127 | 128 | if ( 129 | app.intentsEngineConfig.welcomeIntentId && 130 | intentsEngine.findIntentWithEvent 131 | ) { 132 | const response = await intentsEngine.findIntentWithEvent( 133 | app.intentsEngineConfig.welcomeIntentId, 134 | { 135 | telephony: { 136 | caller_id: voiceRequest.callerNumber 137 | } 138 | } 139 | ) 140 | if (response.effects.length > 0) { 141 | await voiceResponse.say( 142 | response.effects[0].parameters["response"] as string, 143 | voiceConfig 144 | ) 145 | } else { 146 | logger.warn( 147 | `no effects found for welcome intent: trigger '${app.intentsEngineConfig.welcomeIntentId}'` 148 | ) 149 | } 150 | } 151 | 152 | const cerebro = new Cerebro({ 153 | voiceRequest, 154 | voiceResponse, 155 | eventsClient, 156 | voiceConfig, 157 | intentsEngine, 158 | activationIntentId: app.activationIntentId, 159 | activationTimeout: app.activationTimeout, 160 | transfer: app.transferConfig, 161 | alternativeLanguageCode: app.speechConfig.languageCode 162 | }) 163 | 164 | // Open for bussiness 165 | await cerebro.wake() 166 | } catch (e) { 167 | ulogger({ 168 | accessKeyId: voiceRequest.accessKeyId, 169 | eventType: ULogType.APP, 170 | level: "error", 171 | message: (e as Error).message 172 | }) 173 | } 174 | } 175 | ) 176 | } 177 | -------------------------------------------------------------------------------- /test/rox.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 by Fonoster Inc (https://fonoster.com) 3 | * http://github.com/fonoster/fonoster 4 | * 5 | * This file is part of Rox AI 6 | * 7 | * Licensed under the MIT License (the "License"); 8 | * you may not use this file except in compliance with 9 | * the License. You may obtain a copy of the License at 10 | * 11 | * https://opensource.org/licenses/MIT 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | describe("@fonoster/rox", () => { 20 | it("needs tests") 21 | }) 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "downlevelIteration": true, 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "noImplicitAny": false, 14 | "noImplicitReturns": false, 15 | "pretty": true, 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "strictPropertyInitialization": false, 21 | "target": "esnext", 22 | "outDir": "./dist", 23 | "rootDir": "src", 24 | "incremental": false 25 | }, 26 | "exclude": [ 27 | "**/node_modules", 28 | "**/dist" 29 | ], 30 | "include": [ 31 | "src" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------