From 97d6d55bc8fd610ac47355fbc07e3db2f30a2aa3 Mon Sep 17 00:00:00 2001
From: Teodor Ionescu <31542280+tgbv@users.noreply.github.com>
Date: Sat, 12 Oct 2024 00:14:10 +0300
Subject: [PATCH] feat: v2 with its features
---
README.md | 18 ++---
package.json | 2 +-
src/commands/create-call.ts | 8 +++
src/commands/init.ts | 71 +++++++++----------
src/commands/set-token.ts | 55 ---------------
src/commands/snapshot.ts | 132 +++++++++++++++++++++---------------
src/index.ts | 16 ++---
src/lib/api.ts | 59 +++++++++++++---
src/lib/index.ts | 3 +-
9 files changed, 182 insertions(+), 182 deletions(-)
delete mode 100644 src/commands/set-token.ts
diff --git a/README.md b/README.md
index 088fd1c..defe6ed 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
# cognigy-vg
-CLI to interact with Cognigy Voice Gateway product. Not affiliated with Cognigy or its subsidiaries.
+CLI to interact with Cognigy Voice Gateway product. It is account-scoped. Not affiliated with Cognigy or its subsidiaries.
Features include and are not limited to:
- Creating local snapshots (.csnap like).
- Restoring local snapshots remotely.
- Pulling VG resources locally.
- Pushing VG resources remotely.
+- Creating outbound calls.
@@ -33,12 +34,11 @@ Options:
Commands:
init [options] Guided way to initialize new configuration file.
- set [options] Quickly set workspace API key/bearer token.
pull [options] Pull one resource from API to disk. Can be "app", "carrier", "speech", "phone",
"obroutes".
push [options] Push one resource from disk to API. Can be "app", "carrier", "speech", "phone".
clone [options] Clone locally VG app/service provider with all dependencies.
- snapshot [options] [snapshotName] Create or restore a snapshot remotely.
+ snapshot [options] [snapshotName] Create, restore a snapshot remotely, or inspect it locally.
create [options] Guided way to create an outbound call.
help [command] display help for command
```
@@ -51,15 +51,9 @@ Usually the FQDN has the scheme: `api-{VG tenant FQDN}`.
Trial tenant FQDN is `vg-trial.cognigy.ai`. Therefore the trial API FQDN is: `api-vg-trial.cognigy.ai`.
-### Using the right Bearer Token
+### Using the right API Key
-If Cognigy does not provide you an API key with permissions to both Account and BYO Services out of the box, you'll have to request one. Alternatively, you may fastly retrieve one via hacky way. Ensure your account has permissions to access the BYO Services, then do the following:
-
-1. Login to Cognigy VG panel
-2. Hit F12
-3. Go to network tab
-4. Navigate any page which fires a GET XHR to the API. Should be any of them.
-5. Locate the XHR, locate its Authorization header. The value after 'Bearer' is the token you need.
+Unlike v1, you will need to request a single ServiceProvider API key which has access to the account you want to work with, and the BYO Services linked to it. Please contact Cognigy Support to request one.
### Snapshots
@@ -67,7 +61,7 @@ A snapshot (.vgsnap) is a JSON file containing the entire dump of your VG Accoun
The encryption key of snapshots is generated at project initialization. If you plan on restoring the created snapshots, never ever loose the key from configuration file: `snapEncryptionKey`
-Snapshots are cross-compatible remotely between different VG accounts / byo services.
+Snapshots are cross-compatible remotely between different VG accounts / byo services, but may not be compatible locally between major CLI package versions. If there's incompatibility, you will be notified when executing 'snapshot restore' command.
### Speech services not working after restoration?
diff --git a/package.json b/package.json
index 84e49dc..031c0e7 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/tgbv/cognigy-vg.git"
},
- "version": "1.2.1",
+ "version": "2.0.0",
"license": "MIT",
"description": "CLI to interact with Cognigy Voice Gateway.",
"keywords": [
diff --git a/src/commands/create-call.ts b/src/commands/create-call.ts
index 612ed73..6601742 100644
--- a/src/commands/create-call.ts
+++ b/src/commands/create-call.ts
@@ -59,11 +59,19 @@ export default async (_, options: any ) => {
}
});
+ let apiKeys = await api.getRemoteAccountApiKeys();
+ if(apiKeys.length === 0) {
+ console.log('WARN: No API key found for account', config.accountSid, '. Generating one...');
+ await api.createRemoteAccountApiKey();
+ apiKeys = await api.getRemoteAccountApiKeys();
+ }
+
await api.createCall(
from[0] === '+' ? from : `+${from}`,
to[0] === '+' ? to : `+${to}`,
phones.find(o => o.number === from).application_sid,
JSON.parse(tag),
+ apiKeys[0].token
);
console.log('Done.');
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 73591d8..df867b2 100644
--- a/src/commands/init.ts
+++ b/src/commands/init.ts
@@ -34,8 +34,10 @@ export default async ({ AU }) => {
{
type: 'text',
name: 'snapEncryptionKey',
- message: 'Snapshots encryption key. Is base64.',
- initial: randomBytes(32).toString('base64')
+ message: 'Snapshots encryption key. Must be base64.',
+ initial: randomBytes(32).toString('base64'),
+ validate: (value) => Buffer.from(value, 'base64').toString().length === 0 ?
+ 'Please input valid base64 encoded data.' : true
},
{
type: 'text',
@@ -61,51 +63,43 @@ export default async ({ AU }) => {
},
], { onCancel: () => process.exit(0) });
-
- const { bearerToken } = await prompts({
+ const { serviceProviderToken } = await prompts({
type: 'text',
- name: 'bearerToken',
- message: 'Bearer token for authentication. Please consult: https://github.com/tgbv/cognigy-vg/tree/dev#using-the-right-bearer-token',
- hint: 'UUIDv4 or JWT',
- validate: (value) => new Promise(accept => {
- getAccounts(apiFqdn, value).then(res => {
- accounts = res;
- getServiceProviders(apiFqdn, value).then(res => {
- if(res.length === 0) {
- accept('This token cannot be used with any ServiceProvider.');
- }
- serviceProviders = res;
- accept(true)
- }).catch(() => accept('Token does not have privileges to retrieve ServiceProviders.'));
- }).catch(() => accept('Token does not have privileges to retrieve own Accounts.'));
- })
+ name: 'serviceProviderToken',
+ message: 'Service Provider API Key',
+ hint: 'UUIDv4',
+ validate: async (value) => {
+ try {
+ serviceProviders = await getServiceProviders(apiFqdn, value);
+ if(serviceProviders.length === 0) {
+ return 'This token cannot be used with any ServiceProvider.';
+ }
+ accounts = await getAccounts(apiFqdn, value);
+ return true;
+ } catch(e) {
+ return 'Token does not have privileges to retrieve own Service Providers / Accounts, or network error occurred.';
+ }
+ }
}, { onCancel: () => process.exit(0) });
- const btSplit = bearerToken.split('.')
- if(btSplit.length === 3) {
- const payload = JSON.parse(Buffer.from(btSplit[1], 'base64').toString('utf8'));
- console.log('WARNING:', 'supplied token will expire in', payload.exp - (Date.now() / 1000) , 'second(s)');
- console.log("You will need to regenerate it. You can do so via 'cognigy-vg set token' command.");
- }
-
const { accountSid, serviceProviderSid } = await prompts([
{
type: 'select',
- name: 'accountSid',
- message: 'Account you will be working with',
- choices: accounts.map(({ name, account_sid }) => ({
+ name: 'serviceProviderSid',
+ message: 'Service provider you will be working with',
+ choices: serviceProviders.map(({ service_provider_sid, name }) => ({
title: name,
- value: account_sid
+ value: service_provider_sid
})),
initial: 0,
},
{
type: 'select',
- name: 'serviceProviderSid',
- message: 'Service provider you will be working with',
- choices: serviceProviders.map(({ service_provider_sid, name }) => ({
+ name: 'accountSid',
+ message: 'Account you will be working with',
+ choices: accounts.map(({ name, account_sid }) => ({
title: name,
- value: service_provider_sid
+ value: account_sid
})),
initial: 0,
},
@@ -130,7 +124,14 @@ export default async ({ AU }) => {
writeFileSync(
`./${fileName}`,
- JSON.stringify({vgSpacePath, apiFqdn, bearerToken, accountSid, serviceProviderSid, snapEncryptionKey }, null, 2)
+ JSON.stringify({
+ vgSpacePath,
+ apiFqdn,
+ serviceProviderToken,
+ accountSid,
+ serviceProviderSid,
+ snapEncryptionKey
+ }, null, 2)
);
console.log(`Configuration file generated: ./${fileName}`);
diff --git a/src/commands/set-token.ts b/src/commands/set-token.ts
deleted file mode 100644
index 03fa502..0000000
--- a/src/commands/set-token.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import prompts from "prompts";
-import axios from "axios";
-import https from "https";
-import { writeFileSync } from "fs";
-import { getAccounts, getServiceProviders, ILocalConfig, loadJsonFile } from "../lib";
-
-export default async (_, { AU, configFile }) => {
- if(AU) {
- axios.interceptors.request.use(cfg => {
- cfg.httpsAgent = new https.Agent({
- rejectUnauthorized: false,
- })
- return cfg;
- });
- }
-
- const config = loadJsonFile(configFile) as ILocalConfig;
-
- const { bearerToken } = await prompts({
- type: 'text',
- name: 'bearerToken',
- message: 'Bearer token for authentication. Please consult: https://github.com/tgbv/cognigy-vg/tree/dev#using-the-right-bearer-token',
- hint: 'UUIDv4 or JWT',
- validate: (value) => new Promise(accept => {
- getAccounts(config.apiFqdn, value).then(res => {
- if(!res.find(o => (o.account_sid === config.accountSid))) {
- return accept(`Token does not have privileges to access Account: ${config.accountSid}`);
- }
- getServiceProviders(config.apiFqdn, value).then(res => {
- if(!res.find(o => (o.service_provider_sid === config.serviceProviderSid))) {
- return accept(`Token does not have privileges to access ServiceProvider: ${config.serviceProviderSid}`);
- }
- accept(true)
- }).catch(() => accept('Token does not have privileges to retrieve ServiceProviders.'));
- }).catch(() => accept('Token does not have privileges to retrieve own Accounts.'));
- })
- }, { onCancel: () => process.exit(0) });
-
- const btSplit = bearerToken.split('.')
- if(btSplit.length === 3) {
- const payload = JSON.parse(Buffer.from(btSplit[1], 'base64').toString('utf8'));
- console.log('WARNING:', 'supplied token will expire in', payload.exp - (Date.now() / 1000) , 'second(s)');
- console.log("You will need to regenerate it. You can do so via 'cognigy-vg set token' command.");
- }
-
- config.bearerToken = bearerToken;
-
- writeFileSync(
- configFile,
- JSON.stringify(config, null, 2)
- );
-
- console.log(`Access token updated in: ${configFile}`);
- process.exit(0);
-}
diff --git a/src/commands/snapshot.ts b/src/commands/snapshot.ts
index bcbd3af..2c0ae1c 100644
--- a/src/commands/snapshot.ts
+++ b/src/commands/snapshot.ts
@@ -3,6 +3,7 @@ import { confirm, ILocalConfig, loadJsonFile } from "../lib";
import API from "../lib/api";
import { createCipheriv, randomBytes, createDecipheriv } from "crypto";
import { cloneDeep } from "lodash";
+import util from "util";
/**
*
@@ -26,14 +27,8 @@ const create = async (api: API, snapEncryptionKey: string, snapshotName: string)
);
const phones = await api.getRemotePhones();
- const obRouters = await api.getRemoteObRouters();
- await Promise.all(
- obRouters.map(
- o => api.getRemoteObRoutes(o.lcr_sid).then(res => {
- o.routes = res;
- })
- )
- );
+ const obRouter = await api.getRemoteObRouter();
+ obRouter.routes = await api.getRemoteObRoutes(obRouter.lcr_sid);
//// encryption & storage part
@@ -42,7 +37,10 @@ const create = async (api: API, snapEncryptionKey: string, snapshotName: string)
apps,
carriers,
phones,
- obRouters
+ obRouter,
+ snapMeta: {
+ version: 2
+ }
});
const iv = randomBytes(12);
@@ -68,14 +66,13 @@ const create = async (api: API, snapEncryptionKey: string, snapshotName: string)
}
-
/**
*
- * @param api
- * @param snapEncryptionKey
* @param snapshotName
+ * @param config
+ * @returns
*/
-const restore = async (api: API, config: ILocalConfig, snapshotName: string) => {
+const decryptSnap = (snapshotName: string, config: ILocalConfig) => {
const vgSnapFile = readFileSync(`${snapshotName}`);
const iv = Buffer.alloc(12);
@@ -95,9 +92,35 @@ const restore = async (api: API, config: ILocalConfig, snapshotName: string) =>
decipher.update(vgSnapFile.subarray(28)),
decipher.final()
]);
- // writeFileSync('./decrypted.json', vgSnapRaw);
const vgSnap = JSON.parse(vgSnapRaw.toString('utf8'));
+
+ return vgSnap;
+}
+
+
+/**
+ *
+ * @param api
+ * @param config
+ * @param snapshotName
+ * @param force
+ * @returns
+ */
+const restore = async (api: API, config: ILocalConfig, snapshotName: string, force?: boolean) => {
+ const vgSnap = decryptSnap(snapshotName, config);
+
+ if(vgSnap.snapMeta?.version !== 2) {
+ console.log('WARN:', 'Snap version', vgSnap.snapMeta?.version, '!==', 2);
+
+ if(force) {
+ console.log('--force flag is set to true. Continuing...');
+ } else {
+ console.log('Please inspect the snapshot for breaking changes of obRoutes using "snapshot inspect" command, or use an older version of this CLI package to restore it.');
+ console.log('If you wish to carry on even though things may break remotely, supply the --force flag.');
+ return;
+ }
+ }
console.log('Flushing remote configuration ...');
@@ -187,42 +210,50 @@ const restore = async (api: API, config: ILocalConfig, snapshotName: string) =>
})
);
- // create obroutes aka lcroutes aka wakanada...
- await Promise.all(
- vgSnap.obRouters.map(obRouterObject => {
- const clonedObject = cloneDeep(obRouterObject);
- // clonedObject.default_carrier_set_entry_sid = vgSnap.carriers.find(o => o.voip_carrier_sid === clonedObject.default_carrier_set_entry_sid)?.new_voip_carrier_sid || null;
- clonedObject.service_provider_sid = config.serviceProviderSid;
- clonedObject.account_sid = config.accountSid;
- delete clonedObject.routes;
- delete clonedObject.number_routes;
- delete clonedObject.default_carrier_set_entry_sid;
-
- return api.createRemoteObRouter(clonedObject)
- .then(({ sid }) => {
- const routes = cloneDeep(obRouterObject.routes);
- routes.forEach(o => {
- o.lcr_route_sid = '';
- o.lcr_sid = '';
- o.lcr_carrier_set_entries.forEach(ocse => {
- ocse.lcr_route_sid = '';
- ocse.voip_carrier_sid = vgSnap.carriers.find(c => c.voip_carrier_sid === ocse.voip_carrier_sid).new_voip_carrier_sid;
- })
- })
-
- return api.putRemoteObRouterRoutes(sid, routes);
+ // create obroutes aka lcroutes
+ const clonedObRouterObject = cloneDeep(vgSnap.obRouter);
+ clonedObRouterObject.service_provider_sid = config.serviceProviderSid;
+ clonedObRouterObject.account_sid = config.accountSid;
+ delete clonedObRouterObject.routes;
+ delete clonedObRouterObject.number_routes;
+ delete clonedObRouterObject.default_carrier_set_entry_sid;
+
+ await api.createRemoteObRouter(clonedObRouterObject)
+ .then(({ sid }) => {
+ const routes = cloneDeep(vgSnap.obRouter.routes);
+ routes.forEach(o => {
+ o.lcr_route_sid = '';
+ o.lcr_sid = '';
+ o.lcr_carrier_set_entries.forEach(ocse => {
+ ocse.lcr_route_sid = '';
+ ocse.voip_carrier_sid = vgSnap.carriers.find(c => c.voip_carrier_sid === ocse.voip_carrier_sid).new_voip_carrier_sid;
})
+ })
+
+ return api.putRemoteObRouterRoutes(sid, routes);
+ });
- })
- );
console.log('Done restoring snapshot remotely!');
}
/**
*
+ * @param config
+ * @param snapshotName
*/
-export default async (action: 'create' | 'restore', snapshotName: string | null, { y, configFile, AU }) => {
+const inspect = async (config: ILocalConfig, snapshotName: string) => {
+ const vgSnap = decryptSnap(snapshotName, config);
+
+ console.log(
+ util.inspect(vgSnap, {showHidden: false, depth: null, colors: true})
+ );
+}
+
+/**
+ *
+ */
+export default async (action: string, snapshotName: string | null, { y, configFile, AU }) => {
if(action === 'restore' && !snapshotName) {
console.log('"snapshotName" is needed when command is "restore"');
return;
@@ -234,6 +265,7 @@ export default async (action: 'create' | 'restore', snapshotName: string | null,
snapshotName = snapshotName ?? await api.getSnapshotName();
if (
+ action !== 'inspect' &&
!y &&
!await confirm(
'This will overwrite ' +
@@ -244,19 +276,9 @@ export default async (action: 'create' | 'restore', snapshotName: string | null,
return;
}
- /**
- *
- *
- */
- if(action === 'create') {
- return create(api, config.snapEncryptionKey, snapshotName);
- }
-
- /**
- *
- *
- */
- if( action === 'restore' ) {
- return restore(api, config, snapshotName);
+ switch(action) {
+ case 'create': return create(api, config.snapEncryptionKey, snapshotName);
+ case 'restore': return restore(api, config, snapshotName);
+ case 'inspect': return inspect(config, snapshotName);
}
}
diff --git a/src/index.ts b/src/index.ts
index d4edb54..4297501 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,13 +1,11 @@
#!/usr/bin/env node
import { Argument, Command } from "commander";
-import { readFileSync } from "fs";
import initCommand from "./commands/init";
import pullCommand from "./commands/pull";
import pushCommand from "./commands/push";
import cloneCommand from "./commands/clone";
import snapshotCommand from "./commands/snapshot";
-import setToken from "./commands/set-token";
import { loadJsonFile } from "./lib";
import createCallCommand from "./commands/create-call";
@@ -29,13 +27,6 @@ program
.option('-AU', 'Allows unauthorized SSL certificates. Useful if your machine is behind VPN.')
.action(initCommand);
-program
- .command('set token')
- .description('Quickly set workspace API key/bearer token.')
- .option('--configFile ', 'Configuration file path.', './config.json')
- .option('-AU', 'Allows unauthorized SSL certificates. Useful if your machine is behind VPN.')
- .action(setToken);
-
program
.command('pull')
.description('Pull one resource from API to disk. Can be "app", "carrier", "speech", "phone", "obroutes".')
@@ -69,10 +60,11 @@ program
program
.command('snapshot')
- .description('Create or restore a snapshot remotely.')
- .addArgument(new Argument('', 'Must be create or restore.').choices(['create', 'restore']))
- .argument('[snapshotName]', 'Name or path to snapshot. Is required if command is "restore".')
+ .description('Create, restore a snapshot remotely, or inspect it locally.')
+ .addArgument(new Argument('', '').choices(['create', 'restore', 'inspect']))
+ .argument('[snapshotName]', 'Name or path to snapshot. Is required if command is "restore" or "inspect".')
.option('--configFile ', 'Configuration file path.', './config.json')
+ .option('--force', 'Forcefully restore snapshot even if versions mismatch. Can be used with "restore" command.')
.option('-AU', 'Allows unauthorized SSL certificates. Useful if your machine is behind VPN.')
.option('-y', 'Skip confirmations.')
.action(snapshotCommand);
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 73c942c..fe7b0e5 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -7,7 +7,7 @@ import { outputFileSync } from "fs-extra";
export default class API {
protected vgSpacePath: string;
protected apiFqdn: string;
- protected bearerToken: string;
+ protected serviceProviderToken: string;
protected accountSid: string;
protected serviceProviderSid: string;
protected axios: AxiosInstance;
@@ -17,10 +17,10 @@ export default class API {
* @param ILocalConfig
* @param allowUnauthorized
*/
- constructor({ vgSpacePath, apiFqdn, bearerToken, accountSid, serviceProviderSid }: ILocalConfig, allowUnauthorized: boolean) {
+ constructor({ vgSpacePath, apiFqdn, serviceProviderToken, accountSid, serviceProviderSid }: ILocalConfig, allowUnauthorized: boolean) {
this.vgSpacePath = vgSpacePath;
this.apiFqdn = apiFqdn;
- this.bearerToken = bearerToken;
+ this.serviceProviderToken = serviceProviderToken;
this.accountSid = accountSid;
this.serviceProviderSid = serviceProviderSid;
@@ -28,12 +28,30 @@ export default class API {
baseURL: `https://${this.apiFqdn}`,
headers: {
Accept: 'application/json',
- Authorization: `Bearer ${this.bearerToken}`
+ Authorization: `Bearer ${this.serviceProviderToken}`
},
httpsAgent: new https.Agent({ rejectUnauthorized: !allowUnauthorized })
});
- this.axios.interceptors.request.use(config => {
- return config;
+ }
+
+ /**
+ *
+ * @returns
+ */
+ getRemoteAccountApiKeys = async () => {
+ const { data } = await this.axios.get(`/v1/Accounts/${this.accountSid}/ApiKeys`);
+
+ return data;
+ }
+
+ /**
+ *
+ * @returns
+ */
+ createRemoteAccountApiKey = () => {
+ return this.axios.post(`/v1/ApiKeys`, {
+ account_sid: this.accountSid,
+ service_provider_sid: this.serviceProviderSid
});
}
@@ -45,7 +63,13 @@ export default class API {
* @param tag
* @returns
*/
- createCall = async (from: string, to: string, applicationSid: string, tag: Record) => {
+ createCall = async (
+ from: string,
+ to: string,
+ applicationSid: string,
+ tag: Record,
+ applicationToken: string,
+ ) => {
const payload: Record = {
application_sid: applicationSid,
from,
@@ -59,7 +83,11 @@ export default class API {
}
};
- return this.axios.post(`/v1/Accounts/${this.accountSid}/Calls`, payload);
+ return this.axios.post(`/v1/Accounts/${this.accountSid}/Calls`, payload, {
+ headers: {
+ Authorization: `Bearer ${applicationToken}`
+ }
+ });
}
/**
@@ -69,7 +97,7 @@ export default class API {
getRemoteCarriers = async () => {
const { data } = await this.axios.get(`/v1/ServiceProviders/${this.serviceProviderSid}/VoipCarriers`);
- return data;
+ return data.filter(({ account_sid }) => account_sid === this.accountSid);
}
/**
@@ -87,7 +115,7 @@ export default class API {
* @returns
*/
getRemoteSpeechCredentials = async () => {
- const { data } = await this.axios.get(`/v1/ServiceProviders/${this.serviceProviderSid}/SpeechCredentials`);
+ const { data } = await this.axios.get(`/v1/Accounts/${this.accountSid}/SpeechCredentials`);
return data;
}
@@ -99,7 +127,7 @@ export default class API {
getRemotePhones = async () => {
const { data } = await this.axios.get(`/v1/ServiceProviders/${this.serviceProviderSid}/PhoneNumbers`);
- return data;
+ return data.filter(({ account_sid }) => account_sid === this.accountSid);
}
/**
@@ -411,6 +439,15 @@ export default class API {
}
+ /**
+ *
+ * @returns
+ */
+ getRemoteObRouter = async () => {
+ const routers = await this.getRemoteObRouters();
+
+ return routers.find(({ account_sid }) => account_sid === this.accountSid);
+ }
/**
*
diff --git a/src/lib/index.ts b/src/lib/index.ts
index dcb9076..6e8effb 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -5,7 +5,8 @@ import { readFileSync } from "fs";
export interface ILocalConfig {
vgSpacePath: string,
apiFqdn: string,
- bearerToken: string,
+ accountToken: string,
+ serviceProviderToken: string,
accountSid: string,
serviceProviderSid: string,
snapEncryptionKey: string