├── .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 | 
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 | 
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 |
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 | [](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 | Effect ID Description Payload Example
59 |
60 |
61 |
62 |
63 | `say`
64 |
65 |
66 | The Effect will randomly pick a textual response and play it back to the user
67 |
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 |
84 |
85 |
86 |
87 |
88 | `hangup`
89 |
90 |
91 | The hangup Effect will close the call
92 |
93 |
94 | ```json
95 | {
96 | "effect": "hangup"
97 | }
98 | ```
99 |
100 |
101 |
102 |
103 |
104 |
105 | `send_data`
106 |
107 |
108 | Use this Effect send arbitrary data to the client. Note that this only works with clients that subscribe for events
109 |
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 |
123 |
124 |
125 |
126 |
127 | `transfer`
128 |
129 |
130 | Forward call to a different endpoint
131 |
132 |
133 | ```json
134 | {
135 | "effect": "transfer",
136 | "parameters": {
137 | "destination": "17853178070",
138 | "record": true
139 | }
140 | }
141 | ```
142 |
143 |
144 |
145 |
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 |
--------------------------------------------------------------------------------