Compare commits

...

28 Commits

Author SHA1 Message Date
Hoan Luu Huu
77dbe964aa fix soniox stt speech credential validation (#535) 2026-01-23 10:08:45 -05:00
Hoan Luu Huu
3609b8e828 support openai transcribe support auto language (#537) 2026-01-23 07:40:00 -05:00
Hoan Luu Huu
27addfa543 support google gemini tts (#534)
* support google gemini tts

* wip

* wip

* wip

* wip

* wip

* support speech utils
2026-01-22 08:24:05 -05:00
Dave Horton
8181d56a48 fix schema command 2026-01-16 08:40:49 -05:00
Dave Horton
6341132807 Feat/sql improvements (#536)
* add indexes

* update sql editor file

* upgrade schema

* optimize Applications.retrieveAll

* security fixes

* update gh workflows
2026-01-15 08:45:40 -05:00
Matt Hertogs
0bf68b6a9b Fix: Allow media_path updates from REST API (#533)
Added media_path to the list of allowed properties for call updates via REST API.
Includes validation to ensure media_path values are one of: no-media, partial-media, or full-media.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 15:52:23 -05:00
Sam Machin
69046ab5d2 Feat/admin numbers carriers (#532)
* add JAMBONES_ADMIN_CARRIER check to limit creating carriers and numbers

* fix logic
2026-01-07 08:01:44 -05:00
Dave Horton
3f1e756467 wip (#529) 2025-12-22 08:28:37 -05:00
Sam Machin
4201ebbe9c Fix/526 (#528)
* calidate webhook urls on update

* don't remove webhooks if not updated

* valid if object exists
2025-12-19 07:32:20 -05:00
Hoan Luu Huu
e02db2e025 update speech utils version 0.2.27 (#527) 2025-12-18 18:36:23 -05:00
Hoan Luu Huu
dd79813229 cannot fetch voice_call_session (#525) 2025-12-17 07:27:01 -05:00
Hoan Luu Huu
1aa28e8ba0 fixed how to detect obscured key (#524)
* fixed how to detect obscured key

* wip

* wip
2025-12-12 08:56:55 -05:00
Hoan Luu Huu
15f2d92f71 subscription update-quantities validate min voice call sessions (#521)
* subscription update-quantities validate min voice call sessions

* subscription update-quantities validate min voice call sessions

* fixed review comment
2025-12-08 08:12:35 -05:00
Hoan Luu Huu
6ef40a648c allow boostAudioSignal from updateCall (#523) 2025-12-07 08:37:38 -05:00
Hoan Luu Huu
40754deb3e soundhound speech credential support audio endpoint (#520)
* soundhound speech credential support audio endpoint

* soundhound speech credential support audio endpoint

* wip

* wip
2025-11-28 21:47:40 -05:00
Sam Machin
eb681f9ddf force account sip_realm to lowercase (#519) 2025-11-20 07:18:17 -05:00
Sam Machin
486428727a remove activation code from response (#513) 2025-11-12 13:13:09 -05:00
Dave Horton
4c86adf1f7 add index on sip_gateways (inbound,voip_carrier_sid) and trunk_type to predefined carriers (#512) 2025-11-11 10:48:14 -05:00
Anton Voylenko
4f0f8a0f46 chore: bump node version (#509) 2025-11-04 18:06:42 -05:00
Hoan Luu Huu
38afe0da18 update speech util version 0.2.26 (#508) 2025-10-31 07:19:22 -04:00
Hoan Luu Huu
0d66dc9c27 support sonic-3 (#507)
* support sonic-3

* update supported languages
2025-10-30 21:21:27 -04:00
Dave Horton
e9d14e9e38 no need to update api_key use date more than once per minute (#506) 2025-10-28 17:18:22 -04:00
Hoan Luu Huu
1d609135fc support trunk_types in voip_carriers (#496)
* support trunk_types in voip_carriers

* wip

* wip

* wip
2025-10-21 06:47:56 -04:00
Dan Jenkins
16dcd26216 allow disabling of all rate limits (#505) 2025-10-20 10:58:34 -04:00
Hoan Luu Huu
42f4318a17 support gladia stt (#503)
* support gladia stt

* wip

* update verb specification
2025-10-20 04:47:17 -04:00
Sam Machin
0f1f5e9b73 bump dbhelpers for cache change (#504) 2025-10-15 11:38:07 -04:00
Hoan Luu Huu
bcff9b35a6 support houndify stt (#498)
* support houndify stt

* wip

* test houdify stt credential

* wip

* wip

* update verb specification
2025-10-14 00:52:49 -04:00
Hoan Luu Huu
8267ddaffd support elevenlabs different endpoint (#502)
* support elevenlabs different endpoint

* wip

* wip

* wip
2025-10-09 08:20:11 -04:00
31 changed files with 3646 additions and 2488 deletions

View File

@@ -6,22 +6,17 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install Docker Compose
run: |
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm install
- run: npm run jslint
- name: Install Docker Compose
run: |
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
- run: npm test
- run: npm run test:encrypt-decrypt

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: prepare tag
id: prepare_tag
@@ -37,14 +37,14 @@ jobs:
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.db-create

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: prepare tag
id: prepare_tag
@@ -37,14 +37,14 @@ jobs:
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
push: true

View File

@@ -1,10 +1,10 @@
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
FROM --platform=linux/amd64 node:24-alpine AS base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/
FROM base as build
FROM base AS build
COPY package.json package-lock.json ./
@@ -18,6 +18,6 @@ COPY --from=build /opt/app /opt/app/
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
ENV NODE_ENV=$NODE_ENV
CMD [ "node", "app.js" ]

View File

@@ -1,10 +1,10 @@
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
FROM --platform=linux/amd64 node:24-alpine AS base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/
FROM base as build
FROM base AS build
COPY package.json package-lock.json ./
@@ -18,6 +18,6 @@ COPY --from=build /opt/app /opt/app/
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
ENV NODE_ENV=$NODE_ENV
CMD [ "npm", "run", "upgrade-db" ]

View File

@@ -35,6 +35,7 @@ Configuration is provided via environment variables:
|K8S_FEATURE_SERVER_SERVICE_PORT| feature server port(required for K8S) |no|
|JAMBONZ_RECORD_WS_USERNAME| recording websocket username|no|
|JAMBONZ_RECORD_WS_PASSWORD| recording websocket password|no|
|DISABLE_RATE_LIMITS| disable rate limiting|no
#### Database dependency
A mysql database is used to store long-lived objects such as Accounts, Applications, etc. To create the database schema, use or review the scripts in the 'db' folder, particularly:

7
app.js
View File

@@ -170,7 +170,12 @@ if (process.env.JAMBONES_TRUST_PROXY) {
});
}
}
app.use(limiter);
const disableRateLimit = process.env.DISABLE_RATE_LIMITS === 'true' || process.env.DISABLE_RATE_LIMITS === '1';
if (!disableRateLimit) {
app.use(limiter);
}
app.use(helmet());
app.use(helmet.hidePoweredBy());
app.use(nocache());

View File

@@ -162,7 +162,7 @@ regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed num
description VARCHAR(1024),
priority INTEGER NOT NULL COMMENT 'lower priority routes are attempted first',
PRIMARY KEY (lcr_route_sid)
) COMMENT='An ordered list of digit patterns in an LCR table. The patterns are tested in sequence until one matches';
) COMMENT='An ordered list of digit patterns in an LCR table. The pat';
CREATE TABLE lcr
(
@@ -173,7 +173,7 @@ default_carrier_set_entry_sid CHAR(36) COMMENT 'default carrier/route to use whe
service_provider_sid CHAR(36),
account_sid CHAR(36),
PRIMARY KEY (lcr_sid)
) COMMENT='An LCR (least cost routing) table that is used by a service provider or account to make decisions about routing outbound calls when multiple carriers are available.';
) COMMENT='An LCR (least cost routing) table that is used by a service ';
CREATE TABLE password_settings
(
@@ -204,6 +204,7 @@ tech_prefix VARCHAR(16) COMMENT 'tech prefix to prepend to outbound calls to thi
inbound_auth_username VARCHAR(64),
inbound_auth_password VARCHAR(64),
diversion VARCHAR(32),
trunk_type ENUM('static_ip','auth','reg') NOT NULL DEFAULT 'static_ip',
PRIMARY KEY (predefined_carrier_sid)
);
@@ -418,6 +419,7 @@ register_public_ip_in_contact BOOLEAN NOT NULL DEFAULT false,
register_status VARCHAR(4096),
dtmf_type ENUM('rfc2833','tones','info') NOT NULL DEFAULT 'rfc2833',
outbound_sip_proxy VARCHAR(255),
trunk_type ENUM('static_ip','auth','reg') NOT NULL DEFAULT 'static_ip',
PRIMARY KEY (voip_carrier_sid)
) COMMENT='A Carrier or customer PBX that can send or receive calls';
@@ -703,6 +705,12 @@ ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_
CREATE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
CREATE INDEX idx_sip_gateways_inbound_carrier ON sip_gateways (inbound,voip_carrier_sid);
CREATE INDEX idx_sip_gateways_inbound_lookup ON sip_gateways (inbound,netmask,ipv4);
CREATE INDEX idx_sip_gateways_inbound_netmask ON sip_gateways (inbound,netmask);
CREATE INDEX voip_carrier_sid_idx ON sip_gateways (voip_carrier_sid);
ALTER TABLE sip_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk_2 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
@@ -744,4 +752,4 @@ ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (devic
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -546,12 +546,12 @@
<schema><![CDATA[]]></schema>
<comment><![CDATA[A Carrier or customer PBX that can send or receive calls]]></comment>
<location>
<x>16.00</x>
<y>427.00</y>
<x>20.00</x>
<y>418.00</y>
</location>
<size>
<width>293.00</width>
<height>580.00</height>
<height>600.00</height>
</size>
<zorder>6</zorder>
<SQLField>
@@ -749,6 +749,13 @@
<type><![CDATA[VARCHAR(255)]]></type>
<uid><![CDATA[556ABA45-BC63-444D-8CB1-973EFCCF9FE7]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[trunk_type]]></name>
<type><![CDATA[ENUM('static_ip','auth','reg')]]></type>
<defaultValue><![CDATA[static_ip]]></defaultValue>
<notNull><![CDATA[1]]></notNull>
<uid><![CDATA[CCF1560C-349E-4DB9-91E5-120F1EDB7CDE]]></uid>
</SQLField>
<labelWindowIndex><![CDATA[28]]></labelWindowIndex>
<objectComment><![CDATA[A Carrier or customer PBX that can send or receive calls]]></objectComment>
<ui.treeExpanded><![CDATA[1]]></ui.treeExpanded>
@@ -1293,7 +1300,7 @@
<comment><![CDATA[a regex-based pattern match for call routing]]></comment>
<location>
<x>16.00</x>
<y>1007.00</y>
<y>1039.00</y>
</location>
<size>
<width>254.00</width>
@@ -1880,7 +1887,7 @@
</location>
<size>
<width>302.00</width>
<height>260.00</height>
<height>280.00</height>
</size>
<zorder>20</zorder>
<SQLField>
@@ -1959,6 +1966,13 @@
<type><![CDATA[VARCHAR(32)]]></type>
<uid><![CDATA[CE2015BC-8538-4FB0-B4D9-454436FAB1D9]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[trunk_type]]></name>
<type><![CDATA[ENUM('static_ip','auth','reg')]]></type>
<defaultValue><![CDATA[static_ip]]></defaultValue>
<notNull><![CDATA[1]]></notNull>
<uid><![CDATA[F50906E5-2CA5-47D0-BF7B-6CB75EFD83B8]]></uid>
</SQLField>
<labelWindowIndex><![CDATA[17]]></labelWindowIndex>
<ui.treeExpanded><![CDATA[1]]></ui.treeExpanded>
<uid><![CDATA[AF34726D-EDFD-414E-9B44-5243DA9D9497]]></uid>
@@ -2165,8 +2179,8 @@
<schema><![CDATA[]]></schema>
<comment><![CDATA[A phone number that has been assigned to an account]]></comment>
<location>
<x>16.00</x>
<y>1128.00</y>
<x>11.00</x>
<y>1162.00</y>
</location>
<size>
<width>522.00</width>
@@ -2363,8 +2377,8 @@
<y>17.00</y>
</location>
<size>
<width>281.00</width>
<height>280.00</height>
<width>391.00</width>
<height>340.00</height>
</size>
<zorder>7</zorder>
<SQLField>
@@ -2453,6 +2467,13 @@
<notNull><![CDATA[1]]></notNull>
<uid><![CDATA[C5C0043B-100A-4476-BF01-BE0777AE27C0]]></uid>
</SQLField>
<SQLField>
<name><![CDATA[protocol]]></name>
<type><![CDATA[ENUM('udp','tcp','tls', 'tls/srtp')]]></type>
<defaultValue><![CDATA[udp]]></defaultValue>
<objectComment><![CDATA[Outbound call protocol]]></objectComment>
<uid><![CDATA[30661D66-96EC-4B02-995C-5E7EB8A3BD70]]></uid>
</SQLField>
<SQLIndex>
<name><![CDATA[sip_gateway_idx_hostport]]></name>
<fieldName><![CDATA[ipv4]]></fieldName>
@@ -2470,13 +2491,63 @@
<indexNamePrefix><![CDATA[sip_gateway]]></indexNamePrefix>
<uid><![CDATA[1C744DE3-39BD-4EC6-B427-7EB2DD258771]]></uid>
</SQLIndex>
<SQLField>
<name><![CDATA[protocol]]></name>
<type><![CDATA[ENUM('udp','tcp','tls', 'tls/srtp')]]></type>
<defaultValue><![CDATA[udp]]></defaultValue>
<objectComment><![CDATA[Outbound call protocol]]></objectComment>
<uid><![CDATA[30661D66-96EC-4B02-995C-5E7EB8A3BD70]]></uid>
</SQLField>
<SQLIndex>
<name><![CDATA[idx_sip_gateways_inbound_carrier]]></name>
<fieldName><![CDATA[inbound]]></fieldName>
<fieldName><![CDATA[voip_carrier_sid]]></fieldName>
<SQLIndexEntry>
<name><![CDATA[inbound]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[CDE029DC-0C7C-400C-85E9-5005C53B7460]]></fieldUid>
</SQLIndexEntry>
<SQLIndexEntry>
<name><![CDATA[voip_carrier_sid]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[BC25D27E-54E4-4D14-B53D-D1C6254D1D72]]></fieldUid>
</SQLIndexEntry>
<indexNamePrefix><![CDATA[sip_gateways]]></indexNamePrefix>
<uid><![CDATA[BCE047C6-F70E-42AD-9201-FECF1BAD6BEA]]></uid>
</SQLIndex>
<SQLIndex>
<name><![CDATA[idx_sip_gateways_inbound_lookup]]></name>
<fieldName><![CDATA[inbound]]></fieldName>
<fieldName><![CDATA[netmask]]></fieldName>
<fieldName><![CDATA[ipv4]]></fieldName>
<SQLIndexEntry>
<name><![CDATA[inbound]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[CDE029DC-0C7C-400C-85E9-5005C53B7460]]></fieldUid>
</SQLIndexEntry>
<SQLIndexEntry>
<name><![CDATA[netmask]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[717ACB37-EF84-48DC-94E4-2AAC066C0A33]]></fieldUid>
</SQLIndexEntry>
<SQLIndexEntry>
<name><![CDATA[ipv4]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[F18DB7D4-F902-4863-870C-CB07032AE17C]]></fieldUid>
</SQLIndexEntry>
<indexNamePrefix><![CDATA[sip_gateways]]></indexNamePrefix>
<uid><![CDATA[83F405A9-2AE5-415C-9B5E-5E9B92A32F57]]></uid>
</SQLIndex>
<SQLIndex>
<name><![CDATA[idx_sip_gateways_inbound_netmask]]></name>
<fieldName><![CDATA[inbound]]></fieldName>
<fieldName><![CDATA[netmask]]></fieldName>
<SQLIndexEntry>
<name><![CDATA[inbound]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[CDE029DC-0C7C-400C-85E9-5005C53B7460]]></fieldUid>
</SQLIndexEntry>
<SQLIndexEntry>
<name><![CDATA[netmask]]></name>
<prefixSize><![CDATA[]]></prefixSize>
<fieldUid><![CDATA[717ACB37-EF84-48DC-94E4-2AAC066C0A33]]></fieldUid>
</SQLIndexEntry>
<indexNamePrefix><![CDATA[sip_gateways]]></indexNamePrefix>
<uid><![CDATA[8322B9B7-DC3A-4B0D-85A8-2D15E4C51340]]></uid>
</SQLIndex>
<labelWindowIndex><![CDATA[31]]></labelWindowIndex>
<objectComment><![CDATA[A whitelisted sip gateway used for origination/termination]]></objectComment>
<ui.treeExpanded><![CDATA[1]]></ui.treeExpanded>
@@ -2608,6 +2679,7 @@
<SQLField>
<name><![CDATA[speech_synthesis_voice]]></name>
<type><![CDATA[VARCHAR(256)]]></type>
<defaultValue><![CDATA[en-US-Standard-C]]></defaultValue>
<notNull><![CDATA[0]]></notNull>
<uid><![CDATA[929D66F0-64B9-4D7C-AB4B-24F131E1178F]]></uid>
</SQLField>
@@ -3161,17 +3233,17 @@
<overviewPanelHidden><![CDATA[0]]></overviewPanelHidden>
<pageBoundariesVisible><![CDATA[0]]></pageBoundariesVisible>
<PageGridVisible><![CDATA[0]]></PageGridVisible>
<RightSidebarWidth><![CDATA[1393.000000]]></RightSidebarWidth>
<RightSidebarWidth><![CDATA[2944.000000]]></RightSidebarWidth>
<sidebarIndex><![CDATA[2]]></sidebarIndex>
<snapToGrid><![CDATA[0]]></snapToGrid>
<SourceSidebarWidth><![CDATA[0.000000]]></SourceSidebarWidth>
<SQLEditorFileFormatVersion><![CDATA[4]]></SQLEditorFileFormatVersion>
<uid><![CDATA[58C99A00-06C9-478C-A667-C63842E088F3]]></uid>
<windowHeight><![CDATA[1055.000000]]></windowHeight>
<windowLocationX><![CDATA[1845.000000]]></windowLocationX>
<windowLocationY><![CDATA[37.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 544}]]></windowScrollOrigin>
<windowWidth><![CDATA[1670.000000]]></windowWidth>
<windowHeight><![CDATA[965.000000]]></windowHeight>
<windowLocationX><![CDATA[-1886.000000]]></windowLocationX>
<windowLocationY><![CDATA[1072.000000]]></windowLocationY>
<windowScrollOrigin><![CDATA[{0, 0}]]></windowScrollOrigin>
<windowWidth><![CDATA[3221.000000]]></windowWidth>
</SQLDocumentInfo>
<AllowsIndexRenamingOnInsert><![CDATA[1]]></AllowsIndexRenamingOnInsert>
<defaultLabelExpanded><![CDATA[1]]></defaultLabelExpanded>

View File

@@ -231,10 +231,14 @@ const sql = {
],
9005: [
'UPDATE applications SET speech_synthesis_voice = \'en-US-Standard-C\' WHERE speech_synthesis_voice IS NULL AND speech_synthesis_vendor = \'google\' AND speech_synthesis_language = \'en-US\'',
'ALTER TABLE applications MODIFY COLUMN speech_synthesis_voice VARCHAR(255) DEFAULT \'en-US-Standard-C\''
]
'ALTER TABLE applications MODIFY COLUMN speech_synthesis_voice VARCHAR(255) DEFAULT \'en-US-Standard-C\'',
'ALTER TABLE voip_carriers ADD COLUMN trunk_type ENUM(\'static_ip\',\'auth\',\'reg\') NOT NULL DEFAULT \'static_ip\'',
'ALTER TABLE predefined_carriers ADD COLUMN trunk_type ENUM(\'static_ip\',\'auth\',\'reg\') NOT NULL DEFAULT \'static_ip\'',
'CREATE INDEX idx_sip_gateways_inbound_carrier ON sip_gateways (inbound,voip_carrier_sid)',
'CREATE INDEX idx_sip_gateways_inbound_lookup ON sip_gateways (inbound,netmask,ipv4)',
'CREATE INDEX idx_sip_gateways_inbound_netmask ON sip_gateways (inbound,netmask)'
],
};
const doIt = async() => {
let connection;
try {

View File

@@ -47,10 +47,14 @@ class ApiKey extends Model {
}
/**
* update last_used api key for an account
*/
* update last_used api key for an account
* (only if last_used is null or more than a minute ago)
*/
static updateLastUsed(account_sid) {
const sql = 'UPDATE api_keys SET last_used = NOW() WHERE account_sid = ?';
const sql = `UPDATE api_keys
SET last_used = NOW()
WHERE account_sid = ?
AND (last_used IS NULL OR last_used < NOW() - INTERVAL 1 MINUTE)`;
const args = [account_sid];
return new Promise((resolve, reject) => {

View File

@@ -54,9 +54,19 @@ class Application extends Model {
}
static countAll(obj) {
let sql = 'SELECT COUNT(*) AS count FROM applications app WHERE 1 = 1';
const args = [];
sql += Application._criteriaBuilder(obj, args);
const criteriaClause = Application._criteriaBuilder(obj, args);
// Only use "WHERE 1 = 1" if there are no filters
// Otherwise start with the actual filter for better index usage
let sql;
if (criteriaClause) {
// Remove leading ' AND ' from criteriaBuilder output and use as WHERE clause
sql = 'SELECT COUNT(*) AS count FROM applications app WHERE ' + criteriaClause.substring(5);
} else {
// No filters provided - count all applications
sql = 'SELECT COUNT(*) AS count FROM applications app WHERE 1 = 1';
}
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
@@ -123,9 +133,19 @@ class Application extends Model {
}
// No pagination - use original query
let sql = retrieveSql + ' WHERE 1 = 1';
const args = [];
sql += Application._criteriaBuilder(obj, args);
const criteriaClause = Application._criteriaBuilder(obj, args);
// Only use "WHERE 1 = 1" if there are no filters
// Otherwise start with the actual filter for better index usage
let sql;
if (criteriaClause) {
// Remove leading ' AND ' from criteriaBuilder output and use as WHERE clause
sql = retrieveSql + ' WHERE ' + criteriaClause.substring(5);
} else {
// No filters provided - must list all applications
sql = retrieveSql + ' WHERE 1 = 1';
}
sql += ' ORDER BY app.application_sid';
return new Promise((resolve, reject) => {

View File

@@ -140,6 +140,11 @@ router.put('/:sid/VoipCarriers/:voip_carrier_sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
&& !req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
const sid = parseVoipCarrierSid(req);
const account_sid = parseAccountSid(req);
await validateRequest(req, account_sid);
@@ -159,6 +164,10 @@ router.post('/:sid/VoipCarriers', async(req, res) => {
const logger = req.app.locals.logger;
const payload = req.body;
try {
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
|| !!req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
const account_sid = parseAccountSid(req);
await validateRequest(req, account_sid);
// Set the service_provder_sid to the relevent value for the account
@@ -298,7 +307,9 @@ function validateUpdateCall(opts) {
'tag',
'dtmf',
'conferenceParticipantAction',
'dub'
'dub',
'boostAudioSignal',
'media_path'
]
.reduce((acc, prop) => (opts[prop] ? ++acc : acc), 0);
@@ -362,6 +373,9 @@ function validateUpdateCall(opts) {
throw new DbErrorBadRequest('conferenceParticipantAction requires tag property when action is \'coach\'');
}
}
if (opts.media_path && !['no-media', 'partial-media', 'full-media'].includes(opts.media_path)) {
throw new DbErrorBadRequest('invalid media_path');
}
}
function validateTo(to) {
@@ -560,6 +574,8 @@ router.post('/', async(req, res) => {
}
delete obj[prop];
}
//force sip realm to lowercase
if (obj.sip_realm) { obj.sip_realm = obj.sip_realm.toLowerCase(); }
logger.debug(`Attempting to add account ${JSON.stringify(obj)}`);
const uuid = await Account.make(obj);
@@ -802,6 +818,9 @@ router.put('/:sid', async(req, res) => {
encryptBucketCredential(obj, storedBucketCredentials);
//force sip realm to lowercase
if (obj.sip_realm) { obj.sip_realm = obj.sip_realm.toLowerCase();}
const rowsAffected = await Account.update(sid, obj);
if (rowsAffected === 0) {
return res.status(404).end();

View File

@@ -101,6 +101,20 @@ async function validateUpdate(req, sid) {
if (req.body.call_status_hook && typeof req.body.call_hook !== 'object') {
throw new DbErrorBadRequest('\'call_status_hook\' must be an object when updating an application');
}
let urlError;
if (req.body.call_hook) {
urlError = await isInvalidUrl(req.body.call_hook.url);
if (urlError) {
throw new DbErrorBadRequest(`call_hook ${urlError}`);
}
}
if (req.body.call_status_hook) {
urlError = await isInvalidUrl(req.body.call_status_hook.url);
if (urlError) {
throw new DbErrorBadRequest(`call_status_hook ${urlError}`);
}
}
}
async function validateDelete(req, sid) {
@@ -290,9 +304,6 @@ router.put('/:sid', async(req, res) => {
obj[`${prop}_sid`] = sid;
}
}
else {
obj[`${prop}_sid`] = null;
}
delete obj[prop];
}

View File

@@ -19,6 +19,11 @@ const hasWhitespace = (str) => /\s/.test(str);
/* check for required fields when adding */
async function validateAdd(req) {
try {
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
&& !req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
/* account level user can only act on carriers associated to his/her account */
if (req.user.hasAccountAuth) {
req.body.account_sid = req.user.account_sid;

View File

@@ -392,6 +392,8 @@ router.post('/', async(req, res) => {
account_sid: userProfile.account_sid
}, 'generated jwt');
// Remove activation code from the response data!
delete userProfile.email_activation_code;
res.json({jwt: token, ...userProfile});
/* Store jwt based on user_id after successful login */

View File

@@ -45,6 +45,12 @@ const validate = async(req, sid) => {
const {netmask, ipv4, inbound, outbound} = req.body;
let voip_carrier_sid;
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
&& !req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
if (sid) {
const gateway = await lookupSipGatewayBySid(sid);
if (!gateway) throw new DbErrorBadRequest('invalid sip_gateway_sid');

View File

@@ -16,7 +16,9 @@ const {decryptCredential, testWhisper, testDeepgramTTS,
testVoxistStt,
testOpenAiStt,
testInworld,
testResembleTTS} = require('../../utils/speech-utils');
testResembleTTS,
testHoundifyStt,
testGladiaStt} = require('../../utils/speech-utils');
const {DbErrorUnprocessableRequest, DbErrorForbidden, DbErrorBadRequest} = require('../../utils/errors');
const {
testGoogleTts,
@@ -124,6 +126,7 @@ const encryptCredential = (obj) => {
role_arn,
region,
client_id,
client_key,
client_secret,
secret,
nuance_tts_uri,
@@ -162,20 +165,31 @@ const encryptCredential = (obj) => {
voice_engine,
engine_version,
service_version,
api_uri,
houndify_server_uri,
options
} = obj;
switch (vendor) {
case 'google':
assert(service_key, 'invalid json key: service_key is required');
let modified_service_key = service_key;
try {
const o = JSON.parse(service_key);
// support google gemini tts
if (model_id) {
o.model_id = model_id;
} else {
delete o.model_id;
}
assert(o.client_email && o.private_key, 'invalid google service account key');
modified_service_key = JSON.stringify(o);
}
catch (err) {
assert(false, 'invalid google service account key - not JSON');
}
return encrypt(service_key);
return encrypt(modified_service_key);
case 'aws':
// AWS polly can work for 3 types of credentials:
@@ -229,6 +243,10 @@ const encryptCredential = (obj) => {
deepgram_stt_use_tls, deepgram_tts_uri, model_id});
return encrypt(deepgramData);
case 'gladia':
const gladiaData = JSON.stringify({api_key, region});
return encrypt(gladiaData);
case 'resemble':
assert(api_key, 'invalid resemble speech credential: api_key is required');
const resembleData = JSON.stringify({
@@ -265,7 +283,11 @@ const encryptCredential = (obj) => {
case 'elevenlabs':
assert(api_key, 'invalid elevenLabs speech credential: api_key is required');
assert(model_id, 'invalid elevenLabs speech credential: model_id is required');
const elevenlabsData = JSON.stringify({api_key, model_id, options});
const elevenlabsData = JSON.stringify({
api_key,
model_id,
...(api_uri && {api_uri}),
options});
return encrypt(elevenlabsData);
case 'speechmatics':
@@ -313,6 +335,13 @@ const encryptCredential = (obj) => {
const assemblyaiData = JSON.stringify({api_key, service_version});
return encrypt(assemblyaiData);
case 'houndify':
assert(client_id, 'invalid houndify speech credential: client_id is required');
assert(client_key, 'invalid houndify speech credential: client_key is required');
assert(user_id, 'invalid houndify speech credential: user_id is required');
const houndifyData = JSON.stringify({client_id, client_key, user_id, houndify_server_uri});
return encrypt(houndifyData);
case 'voxist':
assert(api_key, 'invalid voxist speech credential: api_key is required');
const voxistData = JSON.stringify({api_key});
@@ -537,7 +566,9 @@ router.put('/:sid', async(req, res) => {
service_version,
speechmatics_stt_uri,
resemble_tts_use_tls,
resemble_tts_uri
resemble_tts_uri,
api_uri,
houndify_server_uri
} = req.body;
const newCred = {
@@ -560,7 +591,7 @@ router.put('/:sid', async(req, res) => {
custom_tts_url,
custom_tts_streaming_url,
cobalt_server_uri,
model_id,
model_id: model_id !== undefined ? model_id : o.model_id,
stt_model_id,
voice_engine,
options,
@@ -572,7 +603,10 @@ router.put('/:sid', async(req, res) => {
service_version,
speechmatics_stt_uri,
resemble_tts_uri,
resemble_tts_use_tls
resemble_tts_use_tls,
api_uri,
houndify_server_uri,
...(vendor === 'google' && {service_key: JSON.stringify(o)})
};
logger.info({o, newCred}, 'updating speech credential with this new credential');
obj.credential = encryptCredential(newCred);
@@ -819,6 +853,18 @@ router.get('/:sid/test', async(req, res) => {
}
}
}
else if (cred.vendor === 'gladia') {
if (cred.use_for_stt) {
try {
await testGladiaStt(logger, credential);
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
results.stt = {status: 'fail', reason: err.message};
SpeechCredential.sttTestResult(sid, false);
}
}
}
else if (cred.vendor === 'ibm') {
const {getTtsVoices} = req.app.locals;
@@ -865,10 +911,10 @@ router.get('/:sid/test', async(req, res) => {
}
}
} else if (cred.vendor === 'elevenlabs') {
const {api_key, model_id} = credential;
const {api_key, model_id, api_uri} = credential;
if (cred.use_for_tts) {
try {
await testElevenlabs(logger, {api_key, model_id});
await testElevenlabs(logger, {api_key, model_id, api_uri});
results.tts.status = 'ok';
SpeechCredential.ttsTestResult(sid, true);
} catch (err) {
@@ -963,6 +1009,17 @@ router.get('/:sid/test', async(req, res) => {
SpeechCredential.sttTestResult(sid, false);
}
}
} else if (cred.vendor === 'houndify') {
if (cred.use_for_stt) {
try {
await testHoundifyStt(logger, credential);
results.stt.status = 'ok';
SpeechCredential.sttTestResult(sid, true);
} catch (err) {
results.stt = {status: 'fail', reason: err.message};
SpeechCredential.sttTestResult(sid, false);
}
}
} else if (cred.vendor === 'voxist') {
const {api_key} = credential;
if (cred.use_for_stt) {

View File

@@ -17,6 +17,7 @@ const {
} = require('../../utils/stripe-utils');
const {setupFreeTrial} = require('./utils');
const sysError = require('../error');
const Product = require('../../models/product');
const actions = [
'upgrade-to-paid',
'downgrade-to-free',
@@ -24,6 +25,8 @@ const actions = [
'update-quantities'
];
const MIN_VOICE_CALL_SESSION_QUANTITY = 5;
const handleError = async(logger, method, res, err) => {
if ('StatusError' === err.name) {
const text = await err.text();
@@ -146,6 +149,22 @@ const upgradeToPaidPlan = async(req, res) => {
await handleSubscriptionOutcome(req, res, subscription);
};
const validateProductQuantities = async(products) => {
const availableProducts = await Product.retrieveAll();
const voiceCallSessionsProductSid =
availableProducts.find((p) => p.category === 'voice_call_session')?.product_sid;
if (voiceCallSessionsProductSid) {
const invalid = products.find((p) => {
return (p.product_sid === voiceCallSessionsProductSid &&
(typeof p.quantity !== 'number' || p.quantity < MIN_VOICE_CALL_SESSION_QUANTITY));
});
if (invalid) {
throw new DbErrorBadRequest('invalid voice call session value, minimum is ' +
MIN_VOICE_CALL_SESSION_QUANTITY);
}
}
};
const downgradeToFreePlan = async(req, res) => {
const logger = req.app.locals.logger;
const {account_sid} = req.user;
@@ -291,11 +310,11 @@ router.post('/', async(req, res) => {
if ('update-payment-method' === action && typeof payment_method_id !== 'string') {
throw new DbErrorBadRequest('missing payment_method_id');
}
if ('upgrade-to-paid' === action && (!Array.isArray(products) || 0 === products.length)) {
throw new DbErrorBadRequest('missing products');
}
if ('update-quantities' === action && (!Array.isArray(products) || 0 === products.length)) {
throw new DbErrorBadRequest('missing products');
if (['update-quantities', 'upgrade-to-paid'].includes(action)) {
if ((!Array.isArray(products) || 0 === products.length)) {
throw new DbErrorBadRequest('missing products');
}
await validateProductQuantities(products);
}
switch (action) {

View File

@@ -9,6 +9,11 @@ const { parseVoipCarrierSid } = require('./utils');
const validate = async(req) => {
const {lookupAppBySid, lookupAccountBySid} = req.app.locals;
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
&& !req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
/* account level user can only act on carriers associated to his/her account */
if (req.user.hasAccountAuth) {
req.body.account_sid = req.user.account_sid;
@@ -45,6 +50,12 @@ const validateUpdate = async(req, sid) => {
const validateDelete = async(req, sid) => {
const {lookupCarrierBySid} = req.app.locals;
if (process.env.JAMBONES_ADMIN_CARRIER == 1 && (!req.user.hasScope('service_provider')
&& !req.user.hasScope('admin'))) {
throw new DbErrorBadRequest('insufficient privileges');
}
if (req.user.hasAccountAuth) {
/* can only update carriers for the user's account */
const carrier = await lookupCarrierBySid(sid);

View File

@@ -13,6 +13,10 @@ const handleInvoicePaymentSucceeded = async(logger, obj) => {
const sub = await retrieveSubscription(logger, subscription);
if ('active' === sub.status) {
const {account_sid} = sub.metadata;
if (!account_sid) {
logger.info({subscription}, `handleInvoicePaymentSucceeded: received subscription ${sub.id} without account_sid`);
return;
}
if (await Account.activateSubscription(logger, account_sid, sub.id,
'subscription_create' === obj.billing_reason ? 'upgrade to paid plan' : 'change plan details')) {
logger.info(`handleInvoicePaymentSucceeded: activated subscription for account ${account_sid}`);
@@ -35,6 +39,10 @@ const handleInvoicePaymentFailed = async(logger, obj) => {
const sub = await retrieveSubscription(logger, subscription);
logger.debug({obj}, `payment for ${obj.billing_reason} failed, subscription status is ${sub.status}`);
const {account_sid} = sub.metadata;
if (!account_sid) {
logger.info({subscription}, `handleInvoicePaymentFailed: received subscription ${sub.id} without account_sid`);
return;
}
if (await Account.deactivateSubscription(logger, account_sid, 'payment failed')) {
logger.info(`handleInvoicePaymentFailed: deactivated subscription for account ${account_sid}`);
}

View File

@@ -50,18 +50,15 @@ function isObscureKey(bucketCredentials) {
service_key = '',
connection_string = ''
} = bucketCredentials || {};
let pattern;
// Pattern matches: 4-6 any characters followed by one or more X's
const pattern = /^.{4,6}X+$/;
switch (vendor) {
case 'aws_s3':
case 's3_compatible':
pattern = /^([A-Za-z0-9]{4,6}X+$)/;
return pattern.test(secret_access_key);
case 'azure':
pattern = /^([A-Za-z0-9:]{4,6}X+$)/;
return pattern.test(connection_string);
case 'google': {
pattern = /^([A-Za-z0-9]{4,6}X+$)/;
let {private_key} = JSON.parse(service_key);
const key_header = '-----BEGIN PRIVATE KEY-----\n';
private_key = private_key.slice(key_header.length, private_key.length);

View File

@@ -0,0 +1,103 @@
module.exports = [
{ name: 'Afrikaans', value: 'af' },
{ name: 'Albanian', value: 'sq' },
{ name: 'Amharic', value: 'am' },
{ name: 'Arabic', value: 'ar' },
{ name: 'Armenian', value: 'hy' },
{ name: 'Assamese', value: 'as' },
{ name: 'Azerbaijani', value: 'az' },
{ name: 'Bashkir', value: 'ba' },
{ name: 'Basque', value: 'eu' },
{ name: 'Belarusian', value: 'be' },
{ name: 'Bengali', value: 'bn' },
{ name: 'Bosnian', value: 'bs' },
{ name: 'Breton', value: 'br' },
{ name: 'Bulgarian', value: 'bg' },
{ name: 'Cantonese', value: 'yue' },
{ name: 'Catalan', value: 'ca' },
{ name: 'Chinese', value: 'zh' },
{ name: 'Croatian', value: 'hr' },
{ name: 'Czech', value: 'cs' },
{ name: 'Danish', value: 'da' },
{ name: 'Dutch', value: 'nl' },
{ name: 'English', value: 'en' },
{ name: 'Estonian', value: 'et' },
{ name: 'Faroese', value: 'fo' },
{ name: 'Finnish', value: 'fi' },
{ name: 'French', value: 'fr' },
{ name: 'Galician', value: 'gl' },
{ name: 'Georgian', value: 'ka' },
{ name: 'German', value: 'de' },
{ name: 'Greek', value: 'el' },
{ name: 'Gujarati', value: 'gu' },
{ name: 'Haitian Creole', value: 'ht' },
{ name: 'Hausa', value: 'ha' },
{ name: 'Hawaiian', value: 'haw' },
{ name: 'Hebrew', value: 'he' },
{ name: 'Hindi', value: 'hi' },
{ name: 'Hungarian', value: 'hu' },
{ name: 'Icelandic', value: 'is' },
{ name: 'Indonesian', value: 'id' },
{ name: 'Italian', value: 'it' },
{ name: 'Japanese', value: 'ja' },
{ name: 'Javanese', value: 'jw' },
{ name: 'Kannada', value: 'kn' },
{ name: 'Kazakh', value: 'kk' },
{ name: 'Khmer', value: 'km' },
{ name: 'Korean', value: 'ko' },
{ name: 'Lao', value: 'lo' },
{ name: 'Latin', value: 'la' },
{ name: 'Latvian', value: 'lv' },
{ name: 'Lingala', value: 'ln' },
{ name: 'Lithuanian', value: 'lt' },
{ name: 'Luxembourgish', value: 'lb' },
{ name: 'Macedonian', value: 'mk' },
{ name: 'Malagasy', value: 'mg' },
{ name: 'Malay', value: 'ms' },
{ name: 'Malayalam', value: 'ml' },
{ name: 'Maltese', value: 'mt' },
{ name: 'Maori', value: 'mi' },
{ name: 'Marathi', value: 'mr' },
{ name: 'Mongolian', value: 'mn' },
{ name: 'Myanmar', value: 'my' },
{ name: 'Nepali', value: 'ne' },
{ name: 'Norwegian', value: 'no' },
{ name: 'Nynorsk', value: 'nn' },
{ name: 'Occitan', value: 'oc' },
{ name: 'Pashto', value: 'ps' },
{ name: 'Persian', value: 'fa' },
{ name: 'Polish', value: 'pl' },
{ name: 'Portuguese', value: 'pt' },
{ name: 'Punjabi', value: 'pa' },
{ name: 'Romanian', value: 'ro' },
{ name: 'Russian', value: 'ru' },
{ name: 'Sanskrit', value: 'sa' },
{ name: 'Serbian', value: 'sr' },
{ name: 'Shona', value: 'sn' },
{ name: 'Sindhi', value: 'sd' },
{ name: 'Sinhala', value: 'si' },
{ name: 'Slovak', value: 'sk' },
{ name: 'Slovenian', value: 'sl' },
{ name: 'Somali', value: 'so' },
{ name: 'Spanish', value: 'es' },
{ name: 'Sundanese', value: 'su' },
{ name: 'Swahili', value: 'sw' },
{ name: 'Swedish', value: 'sv' },
{ name: 'Tagalog', value: 'tl' },
{ name: 'Tajik', value: 'tg' },
{ name: 'Tamil', value: 'ta' },
{ name: 'Tatar', value: 'tt' },
{ name: 'Telugu', value: 'te' },
{ name: 'Thai', value: 'th' },
{ name: 'Tibetan', value: 'bo' },
{ name: 'Turkish', value: 'tr' },
{ name: 'Turkmen', value: 'tk' },
{ name: 'Ukrainian', value: 'uk' },
{ name: 'Urdu', value: 'ur' },
{ name: 'Uzbek', value: 'uz' },
{ name: 'Vietnamese', value: 'vi' },
{ name: 'Welsh', value: 'cy' },
{ name: 'Wolof', value: 'wo' },
{ name: 'Yiddish', value: 'yi' },
{ name: 'Yoruba', value: 'yo' }
];

View File

@@ -0,0 +1,19 @@
module.exports = [
{ name: 'English', value: 'en' },
{ name: 'Spanish', value: 'es' },
{ name: 'Portuguese', value: 'pt' },
{ name: 'French', value: 'fr' },
{ name: 'Indian-accented English', value: 'en-IN' },
{ name: 'German', value: 'de' },
{ name: 'Dutch', value: 'nl' },
{ name: 'Italian', value: 'it' },
{ name: 'Korean', value: 'ko' },
{ name: 'Japanese', value: 'ja' },
{ name: 'Mandarin', value: 'zh-CN' },
{ name: 'Russian', value: 'ru' },
{ name: 'Polish', value: 'pl' },
{ name: 'Swedish', value: 'sv' },
{ name: 'Arabic', value: 'ar' },
{ name: 'Turkish', value: 'tr' },
{ name: 'Hebrew', value: 'he' },
];

View File

@@ -1,4 +1,5 @@
module.exports = [
{ name: 'Auto Language', value: 'auto'},
{ name: 'Afrikaans', value: 'af' },
{ name: 'Arabic', value: 'ar' },
{ name: 'Azerbaijani', value: 'az' },

View File

@@ -9,6 +9,15 @@ module.exports = [
value: 'sonic-2',
languages: ['en', 'fr', 'de', 'es', 'pt', 'zh', 'ja', 'hi', 'it', 'ko', 'nl', 'pl', 'ru', 'sv', 'tr']
},
{
name: 'Sonic 3',
value: 'sonic-3',
languages: [
'en', 'fr', 'de', 'es', 'pt', 'zh', 'ja', 'hi', 'it', 'ko', 'nl', 'pl', 'ru', 'sv', 'tr',
'tl', 'bg', 'ro', 'ar', 'cs', 'el', 'fi', 'hr', 'ms', 'sk', 'da', 'ta', 'uk', 'hu', 'no',
'vi', 'bn', 'th', 'he', 'ka', 'id', 'te', 'gu', 'kn', 'ml', 'mr', 'pa'
]
},
{
name: 'Sonic Turbo',
value: 'sonic-turbo',

View File

@@ -5,6 +5,8 @@ const sdk = require('microsoft-cognitiveservices-speech-sdk');
const { SpeechClient } = require('@soniox/soniox-node');
const fs = require('fs');
const { AssemblyAI } = require('assemblyai');
const Houndify = require('houndify');
const { GladiaClient } = require('@gladiaio/sdk');
const {decrypt, obscureKey} = require('./encrypt-decrypt');
const { RealtimeSession } = require('speechmatics');
@@ -45,9 +47,11 @@ const SttCobaltLanguagesVoices = require('./speech-data/stt-cobalt');
const SttSonioxLanguagesVoices = require('./speech-data/stt-soniox');
const SttSpeechmaticsLanguagesVoices = require('./speech-data/stt-speechmatics');
const SttAssemblyaiLanguagesVoices = require('./speech-data/stt-assemblyai');
const SttHoundifyLanguagesVoices = require('./speech-data/stt-houndify');
const SttVoxistLanguagesVoices = require('./speech-data/stt-voxist');
const SttVerbioLanguagesVoices = require('./speech-data/stt-verbio');
const SttOpenaiLanguagesVoices = require('./speech-data/stt-openai');
const SttGladiaLanguagesVoices = require('./speech-data/stt-gladia');
const SttModelOpenai = require('./speech-data/stt-model-openai');
@@ -66,7 +70,9 @@ const testSonioxStt = async(logger, credentials) => {
return new Promise(async(resolve, reject) => {
try {
const result = await soniox.transcribeFileShort('data/test_audio.wav');
const result = await soniox.transcribeFileShort('data/test_audio.wav', {
model: 'en_v2'
});
if (result.words.length > 0) resolve(result);
else reject(new Error('no transcript returned'));
} catch (error) {
@@ -168,6 +174,65 @@ const testGoogleStt = async(logger, credentials) => {
}
};
const testGladiaStt = async(logger, credentials) => {
const {api_key} = credentials;
try {
const gladiaClient = new GladiaClient({
apiKey: api_key,
});
const gladiaConfig = {
model: 'solaria-1',
encoding: 'wav/pcm',
sample_rate: 16000,
bit_depth: 16,
channels: 1,
language_config: {
languages: ['en'],
code_switching: false,
},
};
// Start the live session
const liveSession = gladiaClient.liveV2().startSession(gladiaConfig);
// Read the test audio file
const audioBuffer = fs.readFileSync(`${__dirname}/../../data/test_audio.wav`);
// Wait for final transcript
return new Promise((resolve, reject) => {
liveSession.on('message', (message) => {
if (message.type === 'transcript' && message.data.is_final) {
logger.debug(`${message.data.id}: ${message.data.utterance.text}`);
liveSession.stopRecording();
resolve(message.data.utterance.text);
}
});
liveSession.on('error', (error) => {
logger.error({error}, 'Gladia Live STT error');
reject(error);
});
// Send audio in chunks
const chunkSize = 1024;
for (let i = 0; i < audioBuffer.length; i += chunkSize) {
const chunk = audioBuffer.slice(i, i + chunkSize);
liveSession.sendAudio(chunk);
}
// Stop recording after sending all audio
liveSession.stopRecording();
// Set a timeout to prevent hanging
setTimeout(() => {
reject(new Error('Gladia STT test timeout'));
}, 30000); // 30 second timeout
});
} catch (error) {
logger.error({error}, 'Failed to create Gladia Live STT session');
throw error;
}
};
const testDeepgramStt = async(logger, credentials) => {
const {api_key, deepgram_stt_uri, deepgram_stt_use_tls} = credentials;
const deepgram = new Deepgram(api_key, deepgram_stt_uri, deepgram_stt_uri && deepgram_stt_use_tls);
@@ -316,8 +381,8 @@ const testWellSaidTts = async(logger, credentials) => {
};
const testElevenlabs = async(logger, credentials) => {
const {api_key, model_id} = credentials;
const response = await fetch('https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM', {
const {api_key, model_id, api_uri} = credentials;
const response = await fetch(`https://${api_uri || 'api.elevenlabs.io'}/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM`, {
method: 'POST',
headers: {
'xi-api-key': api_key,
@@ -595,6 +660,74 @@ const testAssemblyStt = async(logger, credentials) => {
});
};
const testHoundifyStt = async(logger, credentials) => {
const {client_id, client_key, user_id} = credentials;
return new Promise((resolve, reject) => {
try {
// Read the test audio file
const audioBuffer = fs.readFileSync(`${__dirname}/../../data/test_audio.wav`);
// Create VoiceRequest for speech-to-text testing
const voiceRequest = new Houndify.VoiceRequest({
// Your Houndify Client ID and Key
clientId: client_id,
clientKey: client_key,
// Request info
requestInfo: {
UserID: user_id || 'test_user',
Latitude: 37.388309,
Longitude: -121.973968,
},
// custom endpint is used only for feature server.
// ...(houndify_server_uri && {endpoint: houndify_server_uri}),
// Audio format configuration
sampleRate: 16000,
enableVAD: true,
// Response and error handlers
onResponse: function(response, info) {
logger.debug({response, info}, 'Houndify STT response received');
if (response && response.AllResults && response.AllResults.length > 0) {
resolve(response);
} else {
reject(new Error('No transcription results received'));
}
},
onError: function(err, info) {
logger.error({err, info}, 'Houndify STT error');
reject(err);
},
onRecordingStarted: function() {
logger.debug('Houndify recording started');
},
onRecordingStopped: function() {
logger.debug('Houndify recording stopped');
}
});
// Send audio in chunks (VoiceRequest automatically starts when you write data)
const chunkSize = 1024;
for (let i = 0; i < audioBuffer.length; i += chunkSize) {
const chunk = audioBuffer.slice(i, i + chunkSize);
voiceRequest.write(chunk);
}
// End the request
voiceRequest.end();
} catch (error) {
logger.error({error}, 'Failed to create Houndify VoiceRequest');
reject(error);
}
});
};
const testVoxistStt = async(logger, credentials) => {
const {api_key} = credentials;
const response = await fetch('https://api-asr.voxist.com/clients', {
@@ -648,6 +781,7 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
o.private_key.slice(key_header.length, o.private_key.length)}`
};
obj.service_key = JSON.stringify(obscured);
obj.model_id = o.model_id || null;
}
else if ('aws' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -690,6 +824,10 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
const o = JSON.parse(decrypt(credential));
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
}
else if ('gladia' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
}
else if ('ibm' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.tts_api_key = isObscureKey ? obscureKey(o.tts_api_key) : o.tts_api_key;
@@ -714,6 +852,7 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
const o = JSON.parse(decrypt(credential));
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
obj.model_id = o.model_id;
obj.api_uri = o.api_uri;
obj.options = o.options;
} else if ('playht' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -748,7 +887,13 @@ function decryptCredential(obj, credential, logger, isObscureKey = true) {
const o = JSON.parse(decrypt(credential));
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
obj.service_version = o.service_version;
} else if ('resemble' === obj.vendor) {
} else if ('houndify' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_key = isObscureKey ? obscureKey(o.client_key) : o.client_key;
obj.client_id = o.client_id;
obj.user_id = o.user_id;
obj.houndify_server_uri = o.houndify_server_uri;
} else if ('resemble' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = isObscureKey ? obscureKey(o.api_key) : o.api_key;
obj.resemble_tts_uri = o.resemble_tts_uri;
@@ -807,6 +952,8 @@ async function getLanguagesAndVoicesForVendor(logger, vendor, credential, getTts
return await getLanguagesVoicesForNuane(credential, getTtsVoices, logger);
case 'deepgram':
return await getLanguagesVoicesForDeepgram(credential, getTtsVoices, logger);
case 'gladia':
return await getLanguagesVoicesForGladia(credential, getTtsVoices, logger);
case 'ibm':
return await getLanguagesVoicesForIbm(credential, getTtsVoices, logger);
case 'nvidia':
@@ -827,6 +974,8 @@ async function getLanguagesAndVoicesForVendor(logger, vendor, credential, getTts
return await getLanguagesAndVoicesForResemble(credential, getTtsVoices, logger);
case 'assemblyai':
return await getLanguagesVoicesForAssemblyAI(credential, getTtsVoices, logger);
case 'houndify':
return await getLanguagesVoicesForHoundify(credential, getTtsVoices, logger);
case 'voxist':
return await getLanguagesVoicesForVoxist(credential, getTtsVoices, logger);
case 'whisper':
@@ -976,6 +1125,11 @@ async function getLanguagesVoicesForDeepgram(credential, getTtsVoices, logger) {
TtsModelDeepgram, sttModelDeepgram.sort((a, b) => a.name.localeCompare(b.name)));
}
async function getLanguagesVoicesForGladia(credential, getTtsVoices, logger) {
return tranform(undefined, SttGladiaLanguagesVoices.sort((a, b) => a.name.localeCompare(b.name)),
undefined, undefined);
}
async function getLanguagesVoicesForIbm(credential, getTtsVoices, logger) {
if (credential) {
try {
@@ -1014,10 +1168,12 @@ async function getLanguagesVoicesForElevenlabs(credential) {
'xi-api-key': credential.api_key
};
const getModelPromise = fetch('https://api.elevenlabs.io/v1/models', {
const api_uri = credential.api_uri || 'api.elevenlabs.io';
const getModelPromise = fetch(`https://${api_uri}/v1/models`, {
headers
});
const getVoicePromise = fetch('https://api.elevenlabs.io/v1/voices', {
const getVoicePromise = fetch(`https://${api_uri}/v1/voices`, {
headers
});
const [langResp, voiceResp] = await Promise.all([getModelPromise, getVoicePromise]);
@@ -1246,6 +1402,10 @@ async function getLanguagesVoicesForAssemblyAI(credential) {
return tranform(undefined, SttAssemblyaiLanguagesVoices);
}
async function getLanguagesVoicesForHoundify(credential) {
return tranform(undefined, SttHoundifyLanguagesVoices);
}
async function getLanguagesVoicesForVoxist(credential) {
return tranform(undefined, SttVoxistLanguagesVoices);
}
@@ -1367,11 +1527,11 @@ function parseGooglelanguagesVoices(data) {
return data.reduce((acc, voice) => {
const languageCode = voice.languageCodes[0];
const existingLanguage = acc.find((lang) => lang.value === languageCode);
if (existingLanguage) {
existingLanguage.voices.push({
value: voice.name,
name: `${voice.name.substring(languageCode.length + 1, voice.name.length)} (${voice.ssmlGender})`
name: `${voice.name.startsWith(languageCode) ?
voice.name.substring(languageCode.length + 1, voice.name.length) : voice.name} (${voice.ssmlGender})`
});
} else {
acc.push({
@@ -1379,7 +1539,8 @@ function parseGooglelanguagesVoices(data) {
name: SttGoogleLanguagesVoices.find((lang) => lang.value === languageCode)?.name || languageCode,
voices: [{
value: voice.name,
name: `${voice.name.substring(languageCode.length + 1, voice.name.length)} (${voice.ssmlGender})`
name: `${voice.name.startsWith(languageCode) ?
voice.name.substring(languageCode.length + 1, voice.name.length) : voice.name} (${voice.ssmlGender})`
}]
});
}
@@ -1624,6 +1785,7 @@ module.exports = {
testNuanceTts,
testNuanceStt,
testDeepgramStt,
testGladiaStt,
testIbmTts,
testIbmStt,
testSonioxStt,
@@ -1643,5 +1805,6 @@ module.exports = {
testCartesia,
testVoxistStt,
testOpenAiStt,
testResembleTTS
testResembleTTS,
testHoundifyStt
};

5065
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,25 +25,27 @@
"@aws-sdk/client-transcribe": "^3.549.0",
"@azure/storage-blob": "^12.17.0",
"@deepgram/sdk": "^1.21.0",
"@gladiaio/sdk": "^0.5.2",
"@google-cloud/speech": "^6.5.0",
"@google-cloud/storage": "^7.9.0",
"@jambonz/db-helpers": "^0.9.12",
"@jambonz/db-helpers": "^0.9.18",
"@jambonz/lamejs": "^1.2.2",
"@jambonz/mw-registrar": "^0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.15",
"@jambonz/speech-utils": "^0.2.23",
"@jambonz/speech-utils": "^0.2.30",
"@jambonz/time-series": "^0.2.8",
"@jambonz/verb-specifications": "^0.0.115",
"@jambonz/verb-specifications": "^0.0.122",
"@soniox/soniox-node": "^1.2.2",
"ajv": "^8.17.1",
"argon2": "^0.40.1",
"assemblyai": "^4.3.4",
"cors": "^2.8.5",
"debug": "^4.3.4",
"express": "^4.19.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"express-rate-limit": "^7.2.0",
"form-data": "^4.0.0",
"helmet": "^7.1.0",
"houndify": "^3.1.14",
"ibm-watson": "^9.0.1",
"is-valid-hostname": "^1.0.2",
"jsonwebtoken": "^9.0.2",

View File

@@ -285,3 +285,177 @@ test('application tests', async(t) => {
}
});
test('application query optimization tests', async(t) => {
const app = require('../app');
try {
let result;
/* Create multiple service providers and accounts to test filtering */
const voip_carrier_sid = await createVoipCarrier(request, 'test-carrier-apps');
const service_provider_sid_1 = await createServiceProvider(request, 'test-sp-1');
const service_provider_sid_2 = await createServiceProvider(request, 'test-sp-2');
const account_sid_1 = await createAccount(request, service_provider_sid_1, 'test-account-1');
const account_sid_2 = await createAccount(request, service_provider_sid_2, 'test-account-2');
/* Create applications for different accounts */
const app1_result = await request.post('/Applications', {
auth: authAdmin,
json: true,
body: {
name: 'test-app-account-1',
account_sid: account_sid_1,
call_hook: { url: 'http://example.com/app1' },
call_status_hook: { url: 'http://example.com/app1/status' }
}
});
const app1_sid = app1_result.sid;
const app2_result = await request.post('/Applications', {
auth: authAdmin,
json: true,
body: {
name: 'test-app-account-2',
account_sid: account_sid_2,
call_hook: { url: 'http://example.com/app2' },
call_status_hook: { url: 'http://example.com/app2/status' }
}
});
const app2_sid = app2_result.sid;
const app3_result = await request.post('/Applications', {
auth: authAdmin,
json: true,
body: {
name: 'another-app-account-1',
account_sid: account_sid_1,
call_hook: { url: 'http://example.com/app3' },
call_status_hook: { url: 'http://example.com/app3/status' }
}
});
const app3_sid = app3_result.sid;
/* Test 1: Query all applications as admin (no filter) - tests WHERE 1=1 fallback */
result = await request.get('/Applications', {
auth: authAdmin,
json: true,
});
t.ok(result.length >= 3, 'admin can see all applications using WHERE 1=1');
const ourApps = result.filter(app =>
[app1_sid, app2_sid, app3_sid].includes(app.application_sid)
);
t.ok(ourApps.length === 3, 'all three test applications are included in results');
/* Test 2: Query applications with name filter (LIKE query) - tests WHERE name LIKE optimization */
result = await request.get('/Applications', {
qs: { name: 'test-app' },
auth: authAdmin,
json: true,
});
t.ok(result.length === 2, 'successfully filtered applications by name prefix');
t.ok(result.every(app => app.name.includes('test-app')), 'all results match name filter');
/* Test 3: Query applications with exact name match - tests WHERE optimization */
result = await request.get('/Applications', {
qs: { name: 'test-app-account-1' },
auth: authAdmin,
json: true,
});
t.ok(result.length === 1, 'successfully filtered applications by exact name');
t.ok(result[0].name === 'test-app-account-1', 'exact name match works correctly');
/* Test 4: Query with name filter that matches nothing */
result = await request.get('/Applications', {
qs: { name: 'nonexistent-app-12345' },
auth: authAdmin,
json: true,
});
t.ok(result.length === 0, 'non-matching name filter returns empty array');
/* Test 5: Query with pagination and name filter - tests countAll optimization */
result = await request.get('/Applications', {
qs: {
name: 'test-app',
page: 1,
page_size: 10
},
auth: authAdmin,
json: true,
});
t.ok(result.data.length === 2, 'pagination with name filter returns correct count');
t.ok(result.total === 2, 'countAll with name filter returns correct total');
t.ok(result.page === 1, 'pagination returns correct page number');
t.ok(result.page_size === 10, 'pagination returns correct page size');
/* Test 6: Query with pagination and no filter - tests WHERE 1=1 fallback */
result = await request.get('/Applications', {
qs: {
page: 1,
page_size: 2
},
auth: authAdmin,
json: true,
});
t.ok(result.data.length === 2, 'pagination without filter returns page_size results');
t.ok(result.total >= 3, 'pagination without filter uses WHERE 1=1 and returns all');
/* Test 7: Create SP-scoped token and verify WHERE service_provider_sid optimization */
const sp1_token_result = await request.post('/ApiKeys', {
auth: authAdmin,
json: true,
body: {
service_provider_sid: service_provider_sid_1
}
});
const sp1_token = sp1_token_result.token;
const sp1_token_sid = sp1_token_result.sid;
result = await request.get('/Applications', {
auth: {bearer: sp1_token},
json: true,
});
t.ok(result.length === 2, 'SP-scoped token sees only their applications via WHERE service_provider_sid');
t.ok(result.every(app => app.account_sid === account_sid_1), 'all apps belong to SP1 accounts');
/* Test 8: SP-scoped token with name filter - tests combined WHERE clause */
result = await request.get('/Applications', {
qs: { name: 'test-app' },
auth: {bearer: sp1_token},
json: true,
});
t.ok(result.length === 1, 'SP-scoped token with name filter combines filters correctly');
t.ok(result[0].name === 'test-app-account-1', 'combined filter returns correct app');
/* Test 9: SP-scoped token with pagination - tests countAll with service_provider_sid */
result = await request.get('/Applications', {
qs: {
page: 1,
page_size: 10
},
auth: {bearer: sp1_token},
json: true,
});
t.ok(result.data.length === 2, 'SP-scoped pagination returns correct count');
t.ok(result.total === 2, 'countAll with service_provider_sid returns correct total');
/* Cleanup tokens */
await deleteObjectBySid(request, '/ApiKeys', sp1_token_sid);
/* Cleanup */
await deleteObjectBySid(request, '/Applications', app1_sid);
await deleteObjectBySid(request, '/Applications', app2_sid);
await deleteObjectBySid(request, '/Applications', app3_sid);
await deleteObjectBySid(request, '/Accounts', account_sid_1);
await deleteObjectBySid(request, '/Accounts', account_sid_2);
await deleteObjectBySid(request, '/VoipCarriers', voip_carrier_sid);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid_1);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid_2);
//t.end();
}
catch (err) {
t.end(err);
}
});

View File

@@ -656,7 +656,8 @@ test('speech credentials tests', async(t) => {
use_for_stt: true,
use_for_tts: false,
api_key: 'asdasdasdasddsadasda',
model_id: 'eleven_multilingual_v2'
model_id: 'eleven_multilingual_v2',
api_uri: 'api.elevenlabs.io'
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for elevenlabs');
@@ -805,6 +806,29 @@ test('speech credentials tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
/* add a credential for houndify */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'houndify',
use_for_stt: true,
client_key: "ClientKey",
client_id: "ClientID",
user_id: "test_user"
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for houndify');
const houndifySid = result.body.sid;
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${houndifySid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential');
/* add a credential for Voxist */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
@@ -946,6 +970,28 @@ test('speech credentials tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential deepgramflux');
/* add a credential for gladia */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'gladia',
use_for_tts: false,
use_for_stt: true,
api_key: 'api_key',
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for Gladia');
const gladiaSid = result.body.sid;
/* delete the credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${gladiaSid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted speech credential for Gladia');
/* Check google supportedLanguagesAndVoices */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/speech/supportedLanguagesAndVoices?vendor=google`, {
resolveWithFullResponse: true,
@@ -1081,6 +1127,124 @@ test('speech credentials tests', async(t) => {
t.ok(result.body.tts.length !== 0, 'successfully get whisper supported languages and voices');
t.ok(result.body.models.length !== 0, 'successfully get whisper supported languages and voices');
/* Check gladia supportedLanguagesAndVoices */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/speech/supportedLanguagesAndVoices?vendor=gladia`, {
resolveWithFullResponse: true,
simple: false,
auth: authAdmin,
json: true,
});
t.ok(result.body.stt.length !== 0, 'successfully get gladia supported languages and voices');
/* add a credential for google with model_id */
result = await request.post(`/Accounts/${account_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
vendor: 'google',
label: 'google_gemini_tts',
service_key: jsonKey,
use_for_tts: true,
use_for_stt: true,
model_id: 'gemini-2.0-flash-exp'
}
});
t.ok(result.statusCode === 201, 'successfully added speech credential for google with model_id');
const google_gemini_sid = result.body.sid;
/* query the credential and verify model_id are stored */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
});
t.ok(result.statusCode === 200, 'successfully retrieved google gemini speech credential');
t.ok(result.body.vendor === 'google', 'vendor is google');
t.ok(result.body.label === 'google_gemini_tts', 'label is correct');
t.ok(result.body.model_id === 'gemini-2.0-flash-exp', 'model_id is correct');
/* update the credential to change model_id */
result = await request.put(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
use_for_tts: true,
use_for_stt: true,
model_id: 'gemini-2.5-flash-preview-native-audio'
}
});
t.ok(result.statusCode === 204, 'successfully updated google gemini speech credential');
/* verify the update */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
});
t.ok(result.statusCode === 200, 'successfully retrieved updated google gemini speech credential');
t.ok(result.body.model_id === 'gemini-2.5-flash-preview-native-audio', 'model_id is updated correctly');
/* update the credential to disable gemini tts */
result = await request.put(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
resolveWithFullResponse: true,
auth: authUser,
json: true,
body: {
use_for_tts: true,
use_for_stt: true,
model_id: null
}
});
t.ok(result.statusCode === 204, 'successfully updated google speech credential to disable gemini tts');
/* verify the update to disable gemini tts */
result = await request.get(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
});
t.ok(result.statusCode === 200, 'successfully retrieved google speech credential after disabling gemini');
t.ok(!result.body.model_id, 'model_id is now null');
/* delete the google gemini credential */
result = await request.delete(`/Accounts/${account_sid}/SpeechCredentials/${google_gemini_sid}`, {
auth: authUser,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted google gemini speech credential');
/* add a credential for google at service provider level with gemini tts */
result = await request.post(`/ServiceProviders/${service_provider_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
body: {
vendor: 'google',
service_key: jsonKey,
use_for_tts: true,
use_for_stt: true,
model_id: 'gemini-2.0-flash-exp'
}
});
t.ok(result.statusCode === 201, 'successfully added google gemini speech credential to service provider');
const sp_google_gemini_sid = result.body.sid;
/* query the service provider credential */
result = await request.get(`/ServiceProviders/${service_provider_sid}/SpeechCredentials`, {
resolveWithFullResponse: true,
auth: authAdmin,
json: true,
});
t.ok(result.statusCode === 200, 'successfully queried service provider speech credentials');
const spCred = result.body.find(c => c.speech_credential_sid === sp_google_gemini_sid);
t.ok(spCred, 'found google gemini credential in service provider credentials');
t.ok(spCred.model_id === 'gemini-2.0-flash-exp', 'model_id is correct for SP credential');
/* delete the service provider google gemini credential */
await deleteObjectBySid(request, `/ServiceProviders/${service_provider_sid}/SpeechCredentials`, sp_google_gemini_sid);
await deleteObjectBySid(request, '/Accounts', account_sid);
await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid);
t.end();