Add Twilio /calltest for store and direct-dial voice path testing.
Enables outbound PSTN probes via TwiML webhooks with Webex result cards, status polling, and optional store CDR enrichment.
This commit is contained in:
parent
a807f22b43
commit
7aa8c37d1d
20 changed files with 1684 additions and 2 deletions
34
.env.example
34
.env.example
|
|
@ -23,7 +23,7 @@ LOG_LEVEL=info
|
||||||
# HTTP API authentication
|
# HTTP API authentication
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Shared secret required to call destructive /:command HTTP endpoints such as
|
# Shared secret required to call destructive /:command HTTP endpoints such as
|
||||||
# /offboarduser, /provision-dect, /provision-vc, /vcmonitor, /bulkavstatuscsv,
|
# /offboarduser, /provision-dect, /provision-vc, /vcmonitor, /calltest, /bulkavstatuscsv,
|
||||||
# /bulkavswitchcsv, /devicesbymodel. Without it, those endpoints fail-closed
|
# /bulkavswitchcsv, /devicesbymodel. Without it, those endpoints fail-closed
|
||||||
# with HTTP 503 — set this to any high-entropy string (e.g. `openssl rand -hex 32`).
|
# with HTTP 503 — set this to any high-entropy string (e.g. `openssl rand -hex 32`).
|
||||||
# Callers send the token as `Authorization: Bearer <token>` or `X-API-Token: <token>`.
|
# Callers send the token as `Authorization: Bearer <token>` or `X-API-Token: <token>`.
|
||||||
|
|
@ -467,6 +467,38 @@ DECT_RELAY_AGENT_TOKEN=
|
||||||
# Optional. Default: /dect-relay/ws. Change only if you also change
|
# Optional. Default: /dect-relay/ws. Change only if you also change
|
||||||
# DECT_RELAY_BOT_URL on the agent side to match.
|
# DECT_RELAY_BOT_URL on the agent side to match.
|
||||||
# DECT_RELAY_PATH=/dect-relay/ws
|
# DECT_RELAY_PATH=/dect-relay/ws
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Twilio /calltest — outbound voice path testing
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Places real PSTN calls via Twilio. Requires a public HTTPS base URL Twilio
|
||||||
|
# can reach for TwiML + status webhooks. Must match your reverse-proxy path
|
||||||
|
# exactly (no trailing slash). Example for NGINX Proxy Manager location
|
||||||
|
# /CollabSupport/ → http://bot-host:1800:
|
||||||
|
#
|
||||||
|
# TWILIO_ACCOUNT_SID=
|
||||||
|
# TWILIO_AUTH_TOKEN=
|
||||||
|
# TWILIO_FROM_NUMBER=+1...
|
||||||
|
# TWILIO_WEBHOOK_BASE_URL=https://your-public-host.example.com/CollabSupport
|
||||||
|
# CALLTEST_ENABLED=true
|
||||||
|
#
|
||||||
|
# Store entry (AA path only):
|
||||||
|
# CALLTEST_DTMF=1
|
||||||
|
# CALLTEST_GREETING_PAUSE_SEC=5
|
||||||
|
# CALLTEST_ANSWER_WAIT_SEC=45
|
||||||
|
# CALLTEST_DIAL_TIMEOUT_SEC=30
|
||||||
|
#
|
||||||
|
# Shared 60s test core (both store + dial modes):
|
||||||
|
# CALLTEST_LISTEN_SEC=60
|
||||||
|
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
|
||||||
|
# CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye.
|
||||||
|
#
|
||||||
|
# Store mode CDR follow-up (uses spark-admin:calling_cdr_read):
|
||||||
|
# CALLTEST_CDR_ENRICH=true
|
||||||
|
# CALLTEST_CDR_DELAY_MS=360000
|
||||||
|
#
|
||||||
|
# Per-store entry overrides: config/calltest-stores.json
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
|
||||||
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
|
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
|
||||||
# DECT_COLLECT_TIMEOUT_MS=15000
|
# DECT_COLLECT_TIMEOUT_MS=15000
|
||||||
|
|
|
||||||
93
commands/callTest.js
Normal file
93
commands/callTest.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// commands/callTest.js
|
||||||
|
// /calltest — Twilio voice path testing (store AA entry or direct dial).
|
||||||
|
|
||||||
|
import {
|
||||||
|
getConfigurationMessage,
|
||||||
|
getSessionForStatus,
|
||||||
|
isCallTestConfigured,
|
||||||
|
startDialTest,
|
||||||
|
startStoreTest,
|
||||||
|
} from '../services/callTest/callTestService.js';
|
||||||
|
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
|
||||||
|
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
|
||||||
|
import { extractRequester } from '../utils/requester.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
function usageMarkdown() {
|
||||||
|
return (
|
||||||
|
'**Usage:** `/calltest <store|store subcommand|dial subcommand>`\n\n' +
|
||||||
|
'**Store path** (auto-attendant → DTMF → 60s test):\n' +
|
||||||
|
'• `/calltest 782` or `/calltest store 782`\n' +
|
||||||
|
'• `/calltest store 782 +12125550100` — override main number\n\n' +
|
||||||
|
'**Direct dial** (answer → 60s test, no AA/DTMF):\n' +
|
||||||
|
'• `/calltest dial +12125550100`\n' +
|
||||||
|
'• `/calltest +12125550100` — shorthand when arg looks like a phone number\n\n' +
|
||||||
|
'**Status:**\n' +
|
||||||
|
'• `/calltest status <testId>`\n\n' +
|
||||||
|
'Each test places a **real outbound call** from the configured Twilio number. ' +
|
||||||
|
'Results post to this room when the call completes (~2–3 min).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCallTest(bot, trigger) {
|
||||||
|
logger('calltest', 'Handler entered');
|
||||||
|
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const parsed = parseCallTestArgs(
|
||||||
|
args.length
|
||||||
|
? args
|
||||||
|
: [
|
||||||
|
query.mode,
|
||||||
|
query.store || query.storeNum,
|
||||||
|
query.dial || query.number,
|
||||||
|
query.testId,
|
||||||
|
].filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsed.kind === 'usage') {
|
||||||
|
await bot.say('markdown', usageMarkdown());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCallTestConfigured()) {
|
||||||
|
await bot.say('markdown', getConfigurationMessage() || 'Call test is not available.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = trigger.roomId || bot?.room?.id || null;
|
||||||
|
const requester = extractRequester(trigger);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (parsed.kind === 'status') {
|
||||||
|
const session = getSessionForStatus(parsed.testId);
|
||||||
|
if (!session) {
|
||||||
|
await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bot.say('markdown', renderCallTestResultMarkdown(session));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === 'store') {
|
||||||
|
await startStoreTest({
|
||||||
|
storeNum: parsed.storeNum,
|
||||||
|
dialOverride: parsed.dialOverride,
|
||||||
|
roomId,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === 'dial') {
|
||||||
|
await startDialTest({
|
||||||
|
dialNumber: parsed.dial,
|
||||||
|
roomId,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('calltest', `Handler error: ${err.message}`, 'error');
|
||||||
|
await bot.say('markdown', `❌ **Call test failed**\n\n${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ const SHORT_HELP = {
|
||||||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||||
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||||
|
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
||||||
|
|
||||||
// Jira
|
// Jira
|
||||||
jirahistory: 'Recent Jira tickets for a store (optionally filtered by component)',
|
jirahistory: 'Recent Jira tickets for a store (optionally filtered by component)',
|
||||||
|
|
@ -149,6 +150,29 @@ const LONG_HELP = {
|
||||||
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
calltest: {
|
||||||
|
title: '/calltest',
|
||||||
|
usage: [
|
||||||
|
'/calltest <store>',
|
||||||
|
'/calltest store <store> [+1...]',
|
||||||
|
'/calltest dial <e164>',
|
||||||
|
'/calltest status <testId>',
|
||||||
|
],
|
||||||
|
examples: [
|
||||||
|
'/calltest 782',
|
||||||
|
'/calltest store 782',
|
||||||
|
'/calltest dial +12125550100',
|
||||||
|
'/calltest status a1b2c3d4-...',
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
'Places a **real outbound call** via Twilio from the configured `TWILIO_FROM_NUMBER`.',
|
||||||
|
'**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).',
|
||||||
|
'**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.',
|
||||||
|
'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.',
|
||||||
|
'Store tests optionally post a **CDR follow-up** ~6 minutes later when `CALLTEST_CDR_ENRICH=true`.',
|
||||||
|
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
|
||||||
|
],
|
||||||
|
},
|
||||||
wohistory: {
|
wohistory: {
|
||||||
title: '/wohistory',
|
title: '/wohistory',
|
||||||
usage: ['/wohistory <store>'],
|
usage: ['/wohistory <store>'],
|
||||||
|
|
@ -253,7 +277,7 @@ const LONG_HELP = {
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
||||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag'] },
|
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'calltest'] },
|
||||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { handleVoiceDiag } from './voiceDiag.js';
|
||||||
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
||||||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||||
|
import { handleCallTest } from './callTest.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each entry:
|
* Each entry:
|
||||||
|
|
@ -69,6 +70,8 @@ export const commands = [
|
||||||
{ name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true },
|
{ name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true },
|
||||||
{ name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, mutating: true },
|
{ name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, mutating: true },
|
||||||
{ name: 'vcmonitor', handler: handleVcMonitor, mutating: true },
|
{ name: 'vcmonitor', handler: handleVcMonitor, mutating: true },
|
||||||
|
// /calltest places real Twilio PSTN calls (store AA path or direct dial).
|
||||||
|
{ name: 'calltest', handler: handleCallTest, mutating: true },
|
||||||
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
|
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
|
||||||
{ name: 'webexhost', handler: handleWebexHost, mutating: true },
|
{ name: 'webexhost', handler: handleWebexHost, mutating: true },
|
||||||
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,
|
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,
|
||||||
|
|
|
||||||
1
config/calltest-stores.json
Normal file
1
config/calltest-stores.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
15
index.js
15
index.js
|
|
@ -52,6 +52,8 @@ import {
|
||||||
ALL_HTTP_COMMAND_KEYS,
|
ALL_HTTP_COMMAND_KEYS,
|
||||||
MUTATING_COMMAND_KEYS,
|
MUTATING_COMMAND_KEYS,
|
||||||
} from './commands/registry.js';
|
} from './commands/registry.js';
|
||||||
|
import twilioCallTestRouter from './routes/twilioCallTest.js';
|
||||||
|
import { isCallTestConfigured } from './services/callTest/callTestService.js';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Express setup
|
// Express setup
|
||||||
|
|
@ -83,6 +85,9 @@ app.use((req, res, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Twilio /calltest webhooks — after path-prefix strip, before /:command catch-all.
|
||||||
|
app.use(twilioCallTestRouter);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|
@ -772,6 +777,16 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCallTestConfigured()) {
|
||||||
|
logger('startup', '/calltest enabled (Twilio voice webhooks active)');
|
||||||
|
} else {
|
||||||
|
logger(
|
||||||
|
'startup',
|
||||||
|
'/calltest disabled — set TWILIO_* env vars and CALLTEST_ENABLED=true to enable',
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Cron Jobs
|
// Cron Jobs
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
90
integrations/twilio/client.js
Normal file
90
integrations/twilio/client.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// integrations/twilio/client.js
|
||||||
|
// Thin Twilio Voice client for /calltest outbound calls + webhook validation.
|
||||||
|
// Uses axios (already a project dep) instead of the twilio SDK to keep the
|
||||||
|
// install surface small.
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
import twilio from 'twilio';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
const LOG_SCOPE = 'twilio:client';
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v || !String(v).trim()) {
|
||||||
|
throw new Error(`Missing required env: ${name}`);
|
||||||
|
}
|
||||||
|
return String(v).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTwilioConfigured() {
|
||||||
|
return !!(
|
||||||
|
process.env.TWILIO_ACCOUNT_SID
|
||||||
|
&& process.env.TWILIO_AUTH_TOKEN
|
||||||
|
&& process.env.TWILIO_FROM_NUMBER
|
||||||
|
&& process.env.TWILIO_WEBHOOK_BASE_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCallTestEnabled() {
|
||||||
|
const flag = String(process.env.CALLTEST_ENABLED || '').toLowerCase();
|
||||||
|
return flag === 'true' || flag === '1' || flag === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function twilioAuth() {
|
||||||
|
const accountSid = requireEnv('TWILIO_ACCOUNT_SID');
|
||||||
|
const authToken = requireEnv('TWILIO_AUTH_TOKEN');
|
||||||
|
return { accountSid, authToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twilio request signature validation (delegates to the official SDK, which
|
||||||
|
* handles array params, port variants, and legacy query-string encoding).
|
||||||
|
* @see https://www.twilio.com/docs/usage/security#validating-requests
|
||||||
|
*/
|
||||||
|
export function validateTwilioSignature(signature, url, params) {
|
||||||
|
const { authToken } = twilioAuth();
|
||||||
|
if (!signature || !url) return false;
|
||||||
|
return twilio.validateRequest(authToken, signature, url, params || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place an outbound voice call. Twilio fetches `voiceUrl` when the callee answers.
|
||||||
|
*/
|
||||||
|
export async function createOutboundCall({ to, voiceUrl, statusCallback, timeoutSec = 30 }) {
|
||||||
|
const from = requireEnv('TWILIO_FROM_NUMBER');
|
||||||
|
const { accountSid, authToken } = twilioAuth();
|
||||||
|
|
||||||
|
logger(LOG_SCOPE, `Creating outbound call to ${to} from ${from}`, 'debug');
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
To: to,
|
||||||
|
From: from,
|
||||||
|
Url: voiceUrl,
|
||||||
|
Method: 'POST',
|
||||||
|
StatusCallback: statusCallback,
|
||||||
|
StatusCallbackMethod: 'POST',
|
||||||
|
Timeout: String(timeoutSec),
|
||||||
|
});
|
||||||
|
for (const event of ['initiated', 'ringing', 'answered', 'completed']) {
|
||||||
|
body.append('StatusCallbackEvent', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`;
|
||||||
|
const resp = await axios.post(url, body.toString(), {
|
||||||
|
auth: { username: accountSid, password: authToken },
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = resp.data;
|
||||||
|
logger(LOG_SCOPE, `Call created sid=${call.sid} status=${call.status}`, 'debug');
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isTwilioConfigured,
|
||||||
|
isCallTestEnabled,
|
||||||
|
createOutboundCall,
|
||||||
|
validateTwilioSignature,
|
||||||
|
};
|
||||||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"twilio": "^6.0.2",
|
||||||
"webex-node-bot-framework": "^2.5.1",
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
|
|
@ -6020,6 +6021,12 @@
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||||
|
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -11123,6 +11130,13 @@
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/scmp": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
|
||||||
|
"deprecated": "Just use Node.js's crypto.timingSafeEqual()",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/sdp": {
|
"node_modules/sdp": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
|
||||||
|
|
@ -11976,6 +11990,33 @@
|
||||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
"license": "Unlicense"
|
"license": "Unlicense"
|
||||||
},
|
},
|
||||||
|
"node_modules/twilio": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/twilio/-/twilio-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RN3TZxUtxLz2HBZVt62+LdZxQbrMVgYKtuzLgwmO7nqKvR+gQS5mCackD9hf4Y7MmoK/bX7tCm7kaJC8kC8zFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"dayjs": "^1.11.9",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"qs": "^6.14.1",
|
||||||
|
"scmp": "^2.1.0",
|
||||||
|
"xmlbuilder": "^13.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/twilio/node_modules/xmlbuilder": {
|
||||||
|
"version": "13.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
|
||||||
|
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-detect": {
|
"node_modules/type-detect": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"graphql-request": "^7.4.0",
|
"graphql-request": "^7.4.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"twilio": "^6.0.2",
|
||||||
"webex-node-bot-framework": "^2.5.1",
|
"webex-node-bot-framework": "^2.5.1",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
89
routes/twilioCallTest.js
Normal file
89
routes/twilioCallTest.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// routes/twilioCallTest.js
|
||||||
|
// Twilio voice + status webhooks for /calltest.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import { validateTwilioSignature } from '../integrations/twilio/client.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
getWebhookBaseUrl,
|
||||||
|
resolveCallTestWebhookUrl,
|
||||||
|
} from '../services/callTest/config.js';
|
||||||
|
import {
|
||||||
|
handleStatusCallback,
|
||||||
|
handleVoiceWebhook,
|
||||||
|
isCallTestConfigured,
|
||||||
|
} from '../services/callTest/callTestService.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function verifyTwilio(req, res, next) {
|
||||||
|
if (!isCallTestConfigured()) {
|
||||||
|
return res.status(503).send('Call test not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = req.get('X-Twilio-Signature') || req.get('x-twilio-signature');
|
||||||
|
const url = resolveCallTestWebhookUrl(req);
|
||||||
|
const params = req.body || {};
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!validateTwilioSignature(signature, url, params)) {
|
||||||
|
logger(
|
||||||
|
'calltest:webhook',
|
||||||
|
`Invalid Twilio signature for ${req.originalUrl} ` +
|
||||||
|
`(validated as ${url}, base=${getWebhookBaseUrl()}, bodyKeys=${Object.keys(params).length})`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
return res.status(403).send('Forbidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('calltest:webhook', `Signature check error: ${err.message}`, 'error');
|
||||||
|
return res.status(503).send('Not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/twilio/calltest/voice', verifyTwilio, (req, res) => {
|
||||||
|
const testId = req.query.testId;
|
||||||
|
const mode = req.query.mode === 'store' ? 'store' : 'dial';
|
||||||
|
const step = String(req.query.step || '').trim();
|
||||||
|
|
||||||
|
if (!testId || !step) {
|
||||||
|
return res.status(400).type('text/xml').send(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><Response><Say>Missing test parameters.</Say><Hangup/></Response>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('calltest:webhook', `Voice testId=${testId} mode=${mode} step=${step}`, 'info');
|
||||||
|
|
||||||
|
const twiml = handleVoiceWebhook(testId, mode, step);
|
||||||
|
res.type('text/xml').send(twiml);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/twilio/calltest/status', verifyTwilio, async (req, res) => {
|
||||||
|
const testId = req.query.testId;
|
||||||
|
if (!testId) {
|
||||||
|
return res.status(400).send('Missing testId');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'calltest:webhook',
|
||||||
|
`Status testId=${testId} CallStatus=${req.body?.CallStatus} CallSid=${req.body?.CallSid}`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleStatusCallback(testId, req.body || {});
|
||||||
|
res.status(200).send('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logger('calltest:webhook', `Status handler error: ${err.message}`, 'error');
|
||||||
|
res.status(500).send('Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
403
services/callTest/callTestService.js
Normal file
403
services/callTest/callTestService.js
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
// services/callTest/callTestService.js
|
||||||
|
// Orchestrates /calltest Twilio outbound calls, webhooks, and Webex notifications.
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createOutboundCall,
|
||||||
|
isCallTestEnabled,
|
||||||
|
isTwilioConfigured,
|
||||||
|
} from '../../integrations/twilio/client.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import {
|
||||||
|
buildStatusWebhookUrl,
|
||||||
|
buildVoiceWebhookUrl,
|
||||||
|
getCallTestConfig,
|
||||||
|
} from './config.js';
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
listActiveByDestination,
|
||||||
|
updateSession,
|
||||||
|
} from './sessionStore.js';
|
||||||
|
import {
|
||||||
|
buildTwiml,
|
||||||
|
initialStepForMode,
|
||||||
|
isCoreStep,
|
||||||
|
} from './twiml.js';
|
||||||
|
import {
|
||||||
|
renderCallTestCdrMarkdown,
|
||||||
|
renderCallTestResultMarkdown,
|
||||||
|
renderCallTestStartedMarkdown,
|
||||||
|
} from '../renderers/callTestRenderer.js';
|
||||||
|
|
||||||
|
const LOG_SCOPE = 'calltest:service';
|
||||||
|
|
||||||
|
const STORE_ENTRY_FAIL_STEPS = ['greeting', 'dtmf', 'wait'];
|
||||||
|
const TERMINAL_CALL_STATUSES = new Set(['completed', 'busy', 'failed', 'no-answer', 'canceled']);
|
||||||
|
const FINALIZE_AFTER_DONE_MS = Number(process.env.CALLTEST_FINALIZE_DELAY_MS) || 5000;
|
||||||
|
const _pendingFinalize = new Map();
|
||||||
|
|
||||||
|
export function isCallTestConfigured() {
|
||||||
|
return isTwilioConfigured() && isCallTestEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasCallAnswered(session) {
|
||||||
|
const events = session.events || {};
|
||||||
|
return !!(events.answered || events['in-progress'] || session.reachedCore);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigurationMessage() {
|
||||||
|
if (!isTwilioConfigured()) {
|
||||||
|
return (
|
||||||
|
'Call test is not configured. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, ' +
|
||||||
|
'`TWILIO_FROM_NUMBER`, and `TWILIO_WEBHOOK_BASE_URL` in `.env`.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isCallTestEnabled()) {
|
||||||
|
return 'Call test is disabled. Set `CALLTEST_ENABLED=true` in `.env`.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyRoom(roomId, markdown) {
|
||||||
|
if (!roomId) return;
|
||||||
|
try {
|
||||||
|
const { default: botClient } = await import('../../integrations/webex/BotClient.js');
|
||||||
|
await botClient.sendMarkdown(roomId, markdown);
|
||||||
|
} catch (err) {
|
||||||
|
logger(LOG_SCOPE, `Failed to notify room ${roomId}: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeResult(session) {
|
||||||
|
const status = session.twilioCallStatus;
|
||||||
|
const answered = wasCallAnswered(session);
|
||||||
|
const reachedDone = session.lastStep === 'done' || (session.reachedCore && session.lastStep === 'outro');
|
||||||
|
|
||||||
|
if (!answered && TERMINAL_CALL_STATUSES.has(status)) {
|
||||||
|
return { result: 'fail', resultReason: `Call ended: ${status || 'no-answer'}` };
|
||||||
|
}
|
||||||
|
if (!answered) {
|
||||||
|
return { result: 'fail', resultReason: 'Call was never answered' };
|
||||||
|
}
|
||||||
|
if (session.failedAt) {
|
||||||
|
return { result: 'fail', resultReason: `Disconnected during entry (${session.failedAt})` };
|
||||||
|
}
|
||||||
|
if (status === 'completed' && (reachedDone || session.reachedCore)) {
|
||||||
|
const dur = session.durationSec;
|
||||||
|
if (dur != null && dur < 10) {
|
||||||
|
return { result: 'warn', resultReason: 'Completed but very short duration (<10s)' };
|
||||||
|
}
|
||||||
|
return { result: 'pass', resultReason: 'Call completed through shared test core' };
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
return { result: 'fail', resultReason: 'Call completed before test core finished' };
|
||||||
|
}
|
||||||
|
if (session.lastStep === 'done' && session.reachedCore) {
|
||||||
|
return { result: 'pass', resultReason: 'Call completed through shared test core' };
|
||||||
|
}
|
||||||
|
if (!session.result && (session.status === 'in_progress' || session.status === 'pending')) {
|
||||||
|
return { result: null, resultReason: 'Call test still in progress' };
|
||||||
|
}
|
||||||
|
return { result: 'fail', resultReason: status ? `Unexpected status: ${status}` : 'Unknown outcome' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleCdrEnrichment(session) {
|
||||||
|
const cfg = getCallTestConfig(session.storeNum);
|
||||||
|
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
|
||||||
|
|
||||||
|
const delay = cfg.cdrDelayMs;
|
||||||
|
logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug');
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { getHistoricalCallActivity } = await import('../phoneService.js');
|
||||||
|
const cdr = await getHistoricalCallActivity(session.personId, 12, {
|
||||||
|
locationName: session.locationName,
|
||||||
|
});
|
||||||
|
const md = renderCallTestCdrMarkdown(session, cdr);
|
||||||
|
await notifyRoom(session.roomId, md);
|
||||||
|
} catch (err) {
|
||||||
|
logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn');
|
||||||
|
await notifyRoom(
|
||||||
|
session.roomId,
|
||||||
|
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, delay).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeSession(testId) {
|
||||||
|
const session = getSession(testId);
|
||||||
|
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result, resultReason } = computeResult(session);
|
||||||
|
if (result == null) return session;
|
||||||
|
|
||||||
|
const next = updateSession(testId, {
|
||||||
|
status: result === 'pass' || result === 'warn' ? 'completed' : 'failed',
|
||||||
|
result,
|
||||||
|
resultReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (next?.roomId) {
|
||||||
|
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next?.mode === 'store') {
|
||||||
|
scheduleCdrEnrichment(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryFinalizeFromVoiceDone(testId) {
|
||||||
|
const session = getSession(testId);
|
||||||
|
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
if (session.lastStep !== 'done' || !session.reachedCore) return session;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const events = { ...session.events };
|
||||||
|
if (!events.answered) {
|
||||||
|
events.answered = events['in-progress'] || events.coreStartedAt || now;
|
||||||
|
}
|
||||||
|
if (!events.completed) {
|
||||||
|
events.completed = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = { events };
|
||||||
|
if (!TERMINAL_CALL_STATUSES.has(session.twilioCallStatus)) {
|
||||||
|
patch.twilioCallStatus = 'completed';
|
||||||
|
}
|
||||||
|
updateSession(testId, patch);
|
||||||
|
|
||||||
|
logger(LOG_SCOPE, `Finalizing testId=${testId} from voice done step`, 'info');
|
||||||
|
return finalizeSession(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFinalizeFromVoiceDone(testId) {
|
||||||
|
if (_pendingFinalize.has(testId)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
_pendingFinalize.delete(testId);
|
||||||
|
void tryFinalizeFromVoiceDone(testId);
|
||||||
|
}, FINALIZE_AFTER_DONE_MS);
|
||||||
|
if (typeof timer.unref === 'function') timer.unref();
|
||||||
|
_pendingFinalize.set(testId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'store'|'dial'} mode
|
||||||
|
* @param {object} opts
|
||||||
|
*/
|
||||||
|
async function startTest(mode, opts) {
|
||||||
|
const cfgMsg = getConfigurationMessage();
|
||||||
|
if (cfgMsg) throw new Error(cfgMsg);
|
||||||
|
|
||||||
|
const testId = randomUUID();
|
||||||
|
const config = getCallTestConfig(opts.storeNum || null);
|
||||||
|
const initialStep = initialStepForMode(mode);
|
||||||
|
|
||||||
|
const active = listActiveByDestination(opts.dialNumber);
|
||||||
|
if (active.length) {
|
||||||
|
const existing = active[0];
|
||||||
|
throw new Error(
|
||||||
|
`A call test is already in progress for ${opts.dialNumber} (testId \`${existing.testId}\`).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createSession(testId, {
|
||||||
|
mode,
|
||||||
|
storeNum: opts.storeNum || null,
|
||||||
|
dialNumber: opts.dialNumber,
|
||||||
|
roomId: opts.roomId || null,
|
||||||
|
requester: opts.requester || null,
|
||||||
|
personId: opts.personId || null,
|
||||||
|
locationId: opts.locationId || null,
|
||||||
|
locationName: opts.locationName || null,
|
||||||
|
config,
|
||||||
|
status: 'in_progress',
|
||||||
|
});
|
||||||
|
|
||||||
|
const voiceUrl = buildVoiceWebhookUrl(testId, mode, initialStep);
|
||||||
|
const statusUrl = buildStatusWebhookUrl(testId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const call = await createOutboundCall({
|
||||||
|
to: opts.dialNumber,
|
||||||
|
voiceUrl,
|
||||||
|
statusCallback: statusUrl,
|
||||||
|
timeoutSec: config.dialTimeoutSec,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSession(testId, { callSid: call.sid, twilioCallStatus: call.status });
|
||||||
|
} catch (err) {
|
||||||
|
updateSession(testId, { status: 'failed', result: 'fail', resultReason: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSession(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startStoreTest({ storeNum, dialOverride, roomId, requester }) {
|
||||||
|
const { resolveStoreMainNumber } = await import('./storeResolver.js');
|
||||||
|
const resolved = await resolveStoreMainNumber(storeNum);
|
||||||
|
const dialNumber = dialOverride || resolved.dialNumber;
|
||||||
|
|
||||||
|
const session = await startTest('store', {
|
||||||
|
storeNum: resolved.storeNum,
|
||||||
|
dialNumber,
|
||||||
|
roomId,
|
||||||
|
requester,
|
||||||
|
personId: resolved.personId,
|
||||||
|
locationId: resolved.locationId,
|
||||||
|
locationName: resolved.locationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDialTest({ dialNumber, roomId, requester }) {
|
||||||
|
const session = await startTest('dial', {
|
||||||
|
dialNumber,
|
||||||
|
roomId,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} testId
|
||||||
|
* @param {'store'|'dial'} mode
|
||||||
|
* @param {string} step
|
||||||
|
*/
|
||||||
|
export function handleVoiceWebhook(testId, mode, step) {
|
||||||
|
const session = getSession(testId);
|
||||||
|
if (!session) {
|
||||||
|
logger(LOG_SCOPE, `Voice webhook for unknown testId ${testId}`, 'warn');
|
||||||
|
return buildTwiml({
|
||||||
|
mode: 'dial',
|
||||||
|
step: 'done',
|
||||||
|
testId,
|
||||||
|
config: getCallTestConfig(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = session.config || getCallTestConfig(session.storeNum);
|
||||||
|
const patch = {
|
||||||
|
lastStep: step,
|
||||||
|
status: 'in_progress',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCoreStep(step)) {
|
||||||
|
patch.reachedCore = true;
|
||||||
|
if (!session.events?.coreStartedAt) {
|
||||||
|
patch.events = { ...session.events, coreStartedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'store' && step === 'dtmf') {
|
||||||
|
patch.events = { ...(patch.events || session.events), dtmfStepAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession(testId, patch);
|
||||||
|
|
||||||
|
if (step === 'done') {
|
||||||
|
scheduleFinalizeFromVoiceDone(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTwiml({ mode, step, testId, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} testId
|
||||||
|
* @param {Record<string, string>} body Twilio status callback body
|
||||||
|
*/
|
||||||
|
export async function handleStatusCallback(testId, body) {
|
||||||
|
const session = getSession(testId);
|
||||||
|
if (!session) {
|
||||||
|
logger(LOG_SCOPE, `Status callback for unknown testId ${testId}`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callStatus = body.CallStatus || body.callStatus;
|
||||||
|
const callSid = body.CallSid || body.callSid;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
logger(
|
||||||
|
LOG_SCOPE,
|
||||||
|
`Status callback testId=${testId} CallStatus=${callStatus} CallSid=${callSid || 'n/a'}`,
|
||||||
|
'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = { ...session.events };
|
||||||
|
if (callStatus && !events[callStatus]) {
|
||||||
|
events[callStatus] = now;
|
||||||
|
}
|
||||||
|
if ((callStatus === 'answered' || callStatus === 'in-progress') && !events.answered) {
|
||||||
|
events.answered = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {
|
||||||
|
twilioCallStatus: callStatus,
|
||||||
|
callSid: callSid || session.callSid,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (callStatus === 'answered' || callStatus === 'in-progress') {
|
||||||
|
patch.status = 'in_progress';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callStatus === 'completed') {
|
||||||
|
const dur = body.CallDuration || body.Duration;
|
||||||
|
if (dur != null) patch.durationSec = Number(dur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If call ends before core and we were still in store entry
|
||||||
|
if (['completed', 'busy', 'failed', 'no-answer', 'canceled'].includes(callStatus)) {
|
||||||
|
if (!session.reachedCore && session.mode === 'store' && session.lastStep) {
|
||||||
|
if (STORE_ENTRY_FAIL_STEPS.includes(session.lastStep)) {
|
||||||
|
patch.failedAt = session.lastStep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession(testId, patch);
|
||||||
|
|
||||||
|
if (TERMINAL_CALL_STATUSES.has(callStatus)) {
|
||||||
|
await finalizeSession(testId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionForStatus(testId) {
|
||||||
|
const session = getSession(testId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.result) return session;
|
||||||
|
|
||||||
|
const { result, resultReason } = computeResult(session);
|
||||||
|
return { ...session, result, resultReason };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryFinalizeFromVoiceDoneForTests(testId) {
|
||||||
|
return tryFinalizeFromVoiceDone(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _clearPendingFinalizeTimersForTests() {
|
||||||
|
for (const timer of _pendingFinalize.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
_pendingFinalize.clear();
|
||||||
|
}
|
||||||
131
services/callTest/config.js
Normal file
131
services/callTest/config.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// services/callTest/config.js
|
||||||
|
// Env defaults + optional per-store entry overrides from config/calltest-stores.json.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const STORES_CONFIG_PATH = path.resolve(__dirname, '../../config/calltest-stores.json');
|
||||||
|
|
||||||
|
let _storeOverridesCache = null;
|
||||||
|
|
||||||
|
function envInt(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw == null || raw === '') return fallback;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envStr(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
return raw != null && String(raw).trim() ? String(raw).trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebhookBaseUrl() {
|
||||||
|
const base = envStr('TWILIO_WEBHOOK_BASE_URL', '');
|
||||||
|
return base.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStoreOverrides() {
|
||||||
|
if (_storeOverridesCache) return _storeOverridesCache;
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(STORES_CONFIG_PATH)) {
|
||||||
|
_storeOverridesCache = {};
|
||||||
|
return _storeOverridesCache;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(STORES_CONFIG_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
_storeOverridesCache = parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
return _storeOverridesCache;
|
||||||
|
} catch {
|
||||||
|
_storeOverridesCache = {};
|
||||||
|
return _storeOverridesCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string|null} storeNum */
|
||||||
|
export function getCallTestConfig(storeNum = null) {
|
||||||
|
const overrides = storeNum ? (loadStoreOverrides()[String(storeNum)] || {}) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dtmf: String(overrides.dtmf ?? envStr('CALLTEST_DTMF', '1')),
|
||||||
|
greetingPauseSec: Number(overrides.greetingPauseSec ?? envInt('CALLTEST_GREETING_PAUSE_SEC', 5)),
|
||||||
|
answerWaitSec: Number(overrides.answerWaitSec ?? envInt('CALLTEST_ANSWER_WAIT_SEC', 45)),
|
||||||
|
dialTimeoutSec: envInt('CALLTEST_DIAL_TIMEOUT_SEC', 30),
|
||||||
|
listenSec: envInt('CALLTEST_LISTEN_SEC', 60),
|
||||||
|
introTts: envStr(
|
||||||
|
'CALLTEST_INTRO_TTS',
|
||||||
|
'Hello. This is an automated connectivity test from Collab Support. We will listen on this line for sixty seconds. Please stay on the line.',
|
||||||
|
),
|
||||||
|
outroTts: envStr(
|
||||||
|
'CALLTEST_OUTRO_TTS',
|
||||||
|
'Thank you. The connectivity test is complete. Goodbye.',
|
||||||
|
),
|
||||||
|
cdrEnrich: (() => {
|
||||||
|
const v = String(process.env.CALLTEST_CDR_ENRICH ?? 'true').toLowerCase();
|
||||||
|
return v === 'true' || v === '1' || v === 'yes';
|
||||||
|
})(),
|
||||||
|
cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVoiceWebhookUrl(testId, mode, step) {
|
||||||
|
const base = getWebhookBaseUrl();
|
||||||
|
const q = new URLSearchParams({ testId, mode, step });
|
||||||
|
return `${base}/twilio/calltest/voice?${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatusWebhookUrl(testId) {
|
||||||
|
const base = getWebhookBaseUrl();
|
||||||
|
const q = new URLSearchParams({ testId });
|
||||||
|
return `${base}/twilio/calltest/status?${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact webhook URL Twilio was given for this inbound request. Uses the same
|
||||||
|
* builders as outbound call setup / TwiML redirects so proxy path stripping
|
||||||
|
* (e.g. NGINX /CollabSupport/ → backend) cannot desync validation.
|
||||||
|
*/
|
||||||
|
export function resolveCallTestWebhookUrl(req) {
|
||||||
|
const routePath = req.path || req.originalUrl?.split('?')[0] || '';
|
||||||
|
|
||||||
|
if (routePath.endsWith('/twilio/calltest/voice')) {
|
||||||
|
const testId = String(req.query?.testId || '').trim();
|
||||||
|
const mode = req.query?.mode === 'store' ? 'store' : 'dial';
|
||||||
|
const step = String(req.query?.step || '').trim();
|
||||||
|
if (!testId || !step) return null;
|
||||||
|
return buildVoiceWebhookUrl(testId, mode, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routePath.endsWith('/twilio/calltest/status')) {
|
||||||
|
const testId = String(req.query?.testId || '').trim();
|
||||||
|
if (!testId) return null;
|
||||||
|
return buildStatusWebhookUrl(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize user input to E.164 (+1...) */
|
||||||
|
export function normalizeE164(input) {
|
||||||
|
if (!input) return null;
|
||||||
|
let s = String(input).trim().replace(/[^\d+]/g, '');
|
||||||
|
if (s.startsWith('+')) {
|
||||||
|
const digits = s.slice(1);
|
||||||
|
if (!/^\d{10,15}$/.test(digits)) return null;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
const digits = s.replace(/\D/g, '');
|
||||||
|
if (digits.length === 10) return `+1${digits}`;
|
||||||
|
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStoreNumberToken(token) {
|
||||||
|
return /^\d{2,4}$/.test(String(token || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetStoreOverridesCacheForTests() {
|
||||||
|
_storeOverridesCache = null;
|
||||||
|
}
|
||||||
43
services/callTest/parseArgs.js
Normal file
43
services/callTest/parseArgs.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// services/callTest/parseArgs.js
|
||||||
|
|
||||||
|
import { isStoreNumberToken, normalizeE164 } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse /calltest args into a command descriptor.
|
||||||
|
* @returns {{ kind: 'usage'|'status'|'store'|'dial', storeNum?: string, dial?: string, dialOverride?: string|null, testId?: string }}
|
||||||
|
*/
|
||||||
|
export function parseCallTestArgs(args = []) {
|
||||||
|
const tokens = (args || []).map((a) => String(a).trim()).filter(Boolean);
|
||||||
|
if (!tokens.length) return { kind: 'usage' };
|
||||||
|
|
||||||
|
const first = tokens[0].toLowerCase();
|
||||||
|
|
||||||
|
if (first === 'status') {
|
||||||
|
const testId = tokens[1];
|
||||||
|
return testId ? { kind: 'status', testId } : { kind: 'usage' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === 'store') {
|
||||||
|
const storeNum = tokens[1];
|
||||||
|
if (!storeNum || !isStoreNumberToken(storeNum)) return { kind: 'usage' };
|
||||||
|
const override = tokens[2] ? normalizeE164(tokens[2]) : null;
|
||||||
|
if (tokens[2] && !override) return { kind: 'usage' };
|
||||||
|
return { kind: 'store', storeNum, dialOverride: override };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === 'dial') {
|
||||||
|
const dial = normalizeE164(tokens[1]);
|
||||||
|
return dial ? { kind: 'dial', dial } : { kind: 'usage' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStoreNumberToken(first)) {
|
||||||
|
const override = tokens[1] ? normalizeE164(tokens[1]) : null;
|
||||||
|
if (tokens[1] && !override) return { kind: 'usage' };
|
||||||
|
return { kind: 'store', storeNum: first, dialOverride: override };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dial = normalizeE164(first);
|
||||||
|
if (dial) return { kind: 'dial', dial };
|
||||||
|
|
||||||
|
return { kind: 'usage' };
|
||||||
|
}
|
||||||
75
services/callTest/sessionStore.js
Normal file
75
services/callTest/sessionStore.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// services/callTest/sessionStore.js
|
||||||
|
// In-memory call-test sessions (TTL ~30 min).
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
const TTL_MS = 30 * 60 * 1000;
|
||||||
|
const SWEEP_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/** @typedef {'store'|'dial'} CallTestMode */
|
||||||
|
/** @typedef {'pending'|'in_progress'|'completed'|'failed'} CallTestStatus */
|
||||||
|
|
||||||
|
const _store = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} testId
|
||||||
|
* @param {object} data
|
||||||
|
*/
|
||||||
|
export function createSession(testId, data) {
|
||||||
|
const session = {
|
||||||
|
testId,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
events: {},
|
||||||
|
lastStep: null,
|
||||||
|
reachedCore: false,
|
||||||
|
failedAt: null,
|
||||||
|
callSid: null,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
_store.set(testId, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(testId) {
|
||||||
|
return _store.get(testId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSession(testId, patch) {
|
||||||
|
const cur = _store.get(testId);
|
||||||
|
if (!cur) return null;
|
||||||
|
const next = { ...cur, ...patch };
|
||||||
|
_store.set(testId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listActiveByDestination(dialNumber) {
|
||||||
|
const norm = String(dialNumber || '').trim();
|
||||||
|
const out = [];
|
||||||
|
for (const [, s] of _store) {
|
||||||
|
if (s.dialNumber !== norm) continue;
|
||||||
|
if (s.status === 'pending' || s.status === 'in_progress') out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allSessions() {
|
||||||
|
return [..._store.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepHandle = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, s] of _store.entries()) {
|
||||||
|
const ts = s.createdAt || 0;
|
||||||
|
if (now - ts > TTL_MS) {
|
||||||
|
logger('calltest:cleanup', `Expired session ${id} (store=${s.storeNum || 'n/a'})`);
|
||||||
|
_store.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (typeof sweepHandle.unref === 'function') sweepHandle.unref();
|
||||||
|
|
||||||
|
export function _clearAllSessionsForTests() {
|
||||||
|
_store.clear();
|
||||||
|
}
|
||||||
81
services/callTest/storeResolver.js
Normal file
81
services/callTest/storeResolver.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// services/callTest/storeResolver.js
|
||||||
|
// Lightweight store main number lookup (same chain as phoneService, no Meraki fan-out).
|
||||||
|
|
||||||
|
import webex from '../../integrations/webex/WebexClient.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
async function getPersonIdByEmail(email) {
|
||||||
|
if (!email) return null;
|
||||||
|
try {
|
||||||
|
const response = await webex.request('GET', 'people', null, { email });
|
||||||
|
const people = response.items || [];
|
||||||
|
return people.length ? people[0].id : null;
|
||||||
|
} catch (err) {
|
||||||
|
logger('calltest:resolver', `Person lookup failed: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDectNetworksForPerson(personId) {
|
||||||
|
if (!personId) return [];
|
||||||
|
try {
|
||||||
|
const response = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
|
||||||
|
const networks = response.dectNetworks || [];
|
||||||
|
return networks.map((net) => ({
|
||||||
|
id: net.id,
|
||||||
|
locationName: net.location?.name || '—',
|
||||||
|
locationId: net.location?.id || '—',
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logger('calltest:resolver', `DECT network lookup failed: ${err.message}`, 'warn');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the store's public main number (auto-attendant DID).
|
||||||
|
*
|
||||||
|
* @param {string} storeNum 2–4 digit store number
|
||||||
|
*/
|
||||||
|
export async function resolveStoreMainNumber(storeNum) {
|
||||||
|
const store = String(storeNum).trim();
|
||||||
|
const padded = store.padStart(5, '0');
|
||||||
|
const email = `ae${padded}@ae.com`;
|
||||||
|
|
||||||
|
logger('calltest:resolver', `Resolving main number for store ${store} (${email})`, 'debug');
|
||||||
|
|
||||||
|
const personId = await getPersonIdByEmail(email);
|
||||||
|
if (!personId) {
|
||||||
|
throw new Error(`No Webex person found for store ${store} (${email})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dectNets = await getDectNetworksForPerson(personId);
|
||||||
|
if (!dectNets.length) {
|
||||||
|
throw new Error(`No DECT network / location found for store ${store}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = dectNets[0];
|
||||||
|
const locationId = net.locationId && net.locationId !== '—' ? net.locationId : null;
|
||||||
|
const locationName = net.locationName && net.locationName !== '—' ? net.locationName : null;
|
||||||
|
|
||||||
|
if (!locationId) {
|
||||||
|
throw new Error(`No locationId on DECT network for store ${store}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationDetails = await webex.request('GET', `telephony/config/locations/${locationId}`).catch(() => null);
|
||||||
|
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|
||||||
|
|| locationDetails?.phoneNumber
|
||||||
|
|| null;
|
||||||
|
|
||||||
|
if (!dialNumber) {
|
||||||
|
throw new Error(`No main number (callingLineId) on location for store ${store}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeNum: store,
|
||||||
|
dialNumber,
|
||||||
|
personId,
|
||||||
|
locationId,
|
||||||
|
locationName,
|
||||||
|
};
|
||||||
|
}
|
||||||
91
services/callTest/twiml.js
Normal file
91
services/callTest/twiml.js
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// services/callTest/twiml.js
|
||||||
|
// Store entry steps + shared core steps for /calltest Twilio voice webhooks.
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildVoiceWebhookUrl,
|
||||||
|
} from './config.js';
|
||||||
|
|
||||||
|
const CORE_STEPS = new Set(['intro', 'listen', 'outro', 'done']);
|
||||||
|
const STORE_ENTRY_STEPS = new Set(['greeting', 'dtmf', 'wait']);
|
||||||
|
|
||||||
|
function xmlEscape(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(testId, mode, step) {
|
||||||
|
const url = xmlEscape(buildVoiceWebhookUrl(testId, mode, step));
|
||||||
|
return `<Redirect method="POST">${url}</Redirect>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {'store'|'dial'} opts.mode
|
||||||
|
* @param {string} opts.step
|
||||||
|
* @param {string} opts.testId
|
||||||
|
* @param {object} opts.config
|
||||||
|
*/
|
||||||
|
export function buildTwiml({ mode, step, testId, config }) {
|
||||||
|
const c = config;
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (mode === 'store' && STORE_ENTRY_STEPS.has(step)) {
|
||||||
|
switch (step) {
|
||||||
|
case 'greeting':
|
||||||
|
body = `<Pause length="${c.greetingPauseSec}"/>\n${redirect(testId, mode, 'dtmf')}`;
|
||||||
|
break;
|
||||||
|
case 'dtmf': {
|
||||||
|
const digits = String(c.dtmf || '1').replace(/[^0-9*#w]/gi, '');
|
||||||
|
body = `<Play digits="wwww${xmlEscape(digits)}"/>\n${redirect(testId, mode, 'wait')}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'wait':
|
||||||
|
body = `<Pause length="${c.answerWaitSec}"/>\n${redirect(testId, mode, 'intro')}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
body = `<Say>Unknown entry step.</Say>\n<Hangup/>`;
|
||||||
|
}
|
||||||
|
} else if (CORE_STEPS.has(step)) {
|
||||||
|
switch (step) {
|
||||||
|
case 'intro':
|
||||||
|
body = `<Say voice="Polly.Joanna">${xmlEscape(c.introTts)}</Say>\n${redirect(testId, mode, 'listen')}`;
|
||||||
|
break;
|
||||||
|
case 'listen':
|
||||||
|
body = `<Pause length="${c.listenSec}"/>\n${redirect(testId, mode, 'outro')}`;
|
||||||
|
break;
|
||||||
|
case 'outro':
|
||||||
|
body = `<Say voice="Polly.Joanna">${xmlEscape(c.outroTts)}</Say>\n${redirect(testId, mode, 'done')}`;
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
body = '<Hangup/>';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
body = '<Hangup/>';
|
||||||
|
}
|
||||||
|
} else if (mode === 'dial' && step === 'intro') {
|
||||||
|
body = `<Say voice="Polly.Joanna">${xmlEscape(c.introTts)}</Say>\n${redirect(testId, mode, 'listen')}`;
|
||||||
|
} else {
|
||||||
|
body = '<Say>Invalid call test step.</Say>\n<Hangup/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<Response>\n${body}\n</Response>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialStepForMode(mode) {
|
||||||
|
return mode === 'store' ? 'greeting' : 'intro';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCoreStep(step) {
|
||||||
|
return CORE_STEPS.has(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStoreEntryStep(step) {
|
||||||
|
return STORE_ENTRY_STEPS.has(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORE_ENTRY_STEP_ORDER = ['greeting', 'dtmf', 'wait'];
|
||||||
|
export const CORE_STEP_ORDER = ['intro', 'listen', 'outro', 'done'];
|
||||||
119
services/renderers/callTestRenderer.js
Normal file
119
services/renderers/callTestRenderer.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// services/renderers/callTestRenderer.js
|
||||||
|
|
||||||
|
function fmtMs(ms) {
|
||||||
|
if (ms == null || !Number.isFinite(ms)) return '—';
|
||||||
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toISOString();
|
||||||
|
} catch {
|
||||||
|
return String(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} session
|
||||||
|
*/
|
||||||
|
export function renderCallTestStartedMarkdown(session) {
|
||||||
|
const modeLabel = session.mode === 'store' ? 'Store path' : 'Direct dial';
|
||||||
|
const storePart = session.storeNum ? ` for store **${session.storeNum}**` : '';
|
||||||
|
return (
|
||||||
|
`**Call test started** (${modeLabel})${storePart}\n\n` +
|
||||||
|
`- **Dialing:** ${session.dialNumber}\n` +
|
||||||
|
`- **Test ID:** \`${session.testId}\`\n` +
|
||||||
|
`- Results will post here when the call completes (~2–3 min).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} session
|
||||||
|
*/
|
||||||
|
export function renderCallTestResultMarkdown(session) {
|
||||||
|
const modeLabel = session.mode === 'store' ? 'Store' : 'Direct';
|
||||||
|
const inProgress = session.result == null
|
||||||
|
&& (session.status === 'in_progress' || session.status === 'pending');
|
||||||
|
const pass = session.result === 'pass';
|
||||||
|
const warn = session.result === 'warn';
|
||||||
|
const icon = inProgress ? '⏳' : (pass || warn ? '✅' : '❌');
|
||||||
|
const headline = inProgress
|
||||||
|
? 'IN PROGRESS'
|
||||||
|
: (pass || warn ? 'PASSED' : 'FAILED');
|
||||||
|
|
||||||
|
let md = `**${icon} Call test ${headline}** (${modeLabel})\n\n`;
|
||||||
|
|
||||||
|
if (session.storeNum) md += `- **Store:** ${session.storeNum}\n`;
|
||||||
|
md += `- **Dialed:** ${session.dialNumber}\n`;
|
||||||
|
md += `- **Test ID:** \`${session.testId}\`\n`;
|
||||||
|
if (session.callSid) md += `- **Twilio CallSid:** \`${session.callSid}\`\n`;
|
||||||
|
|
||||||
|
if (session.resultReason) {
|
||||||
|
md += `- **Result:** ${session.resultReason}\n`;
|
||||||
|
}
|
||||||
|
if (session.failedAt) {
|
||||||
|
md += `- **Failed at:** ${session.failedAt}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = session.events || {};
|
||||||
|
md += '\n**Timings:**\n';
|
||||||
|
if (e.initiated) md += `- Initiated: ${fmtTime(e.initiated)}\n`;
|
||||||
|
if (e.ringing) md += `- Ringing: ${fmtTime(e.ringing)}\n`;
|
||||||
|
if (e.answered) md += `- Answered: ${fmtTime(e.answered)}\n`;
|
||||||
|
if (!e.answered && e['in-progress']) md += `- In progress: ${fmtTime(e['in-progress'])}\n`;
|
||||||
|
if (e.completed) md += `- Completed: ${fmtTime(e.completed)}\n`;
|
||||||
|
|
||||||
|
if (e.answered && e.initiated) {
|
||||||
|
md += `- Ring → answer: ${fmtMs(new Date(e.answered) - new Date(e.initiated))}\n`;
|
||||||
|
}
|
||||||
|
if (e.coreStartedAt) {
|
||||||
|
md += `- Core started: ${fmtTime(e.coreStartedAt)}\n`;
|
||||||
|
}
|
||||||
|
if (e.completed && e.answered) {
|
||||||
|
md += `- Answer → complete: ${fmtMs(new Date(e.completed) - new Date(e.answered))}\n`;
|
||||||
|
}
|
||||||
|
if (session.durationSec != null) {
|
||||||
|
md += `- Total duration: ${session.durationSec}s\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.mode === 'store' && e.dtmfStepAt) {
|
||||||
|
md += `- Entry → DTMF step: ${fmtTime(e.dtmfStepAt)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} session
|
||||||
|
* @param {object} cdr
|
||||||
|
*/
|
||||||
|
export function renderCallTestCdrMarkdown(session, cdr) {
|
||||||
|
if (!cdr?.available) {
|
||||||
|
return (
|
||||||
|
`**Call test CDR** (\`${session.testId}\`)\n\n` +
|
||||||
|
`_Detailed call history unavailable:_ ${cdr?.reason || 'not configured'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = cdr.summary || {};
|
||||||
|
let md =
|
||||||
|
`**Call test CDR enrichment** (store **${session.storeNum}**)\n\n` +
|
||||||
|
`- **Window:** ${cdr.startTime} → ${cdr.endTime}\n` +
|
||||||
|
`- **Location:** ${cdr.location || '—'}\n` +
|
||||||
|
`- **Matching legs:** ${summary.totalCalls ?? cdr.rawCount ?? 0}\n`;
|
||||||
|
|
||||||
|
if (summary.inbound != null) md += `- Inbound legs: ${summary.inbound}\n`;
|
||||||
|
if (summary.missed != null) md += `- Missed / failed legs: ${summary.missed}\n`;
|
||||||
|
|
||||||
|
const samples = cdr.samples || [];
|
||||||
|
if (samples.length) {
|
||||||
|
md += '\n**Sample legs:**\n';
|
||||||
|
for (const s of samples.slice(0, 5)) {
|
||||||
|
md += `- ${s.start || '—'} | ${s.direction || '—'} | ${s.duration ?? '—'}s | ${s.otherParty || s.phoneNumber || '—'}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
145
tests/callTest.service.test.js
Normal file
145
tests/callTest.service.test.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// tests/callTest.service.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleStatusCallback,
|
||||||
|
handleVoiceWebhook,
|
||||||
|
tryFinalizeFromVoiceDoneForTests,
|
||||||
|
_clearPendingFinalizeTimersForTests,
|
||||||
|
} from '../services/callTest/callTestService.js';
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
_clearAllSessionsForTests,
|
||||||
|
getSession,
|
||||||
|
} from '../services/callTest/sessionStore.js';
|
||||||
|
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
|
||||||
|
|
||||||
|
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||||
|
|
||||||
|
test('handleVoiceWebhook: store path marks core on intro', () => {
|
||||||
|
_clearAllSessionsForTests();
|
||||||
|
createSession(TEST_ID, {
|
||||||
|
mode: 'store',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
config: { listenSec: 60, introTts: 'x', outroTts: 'y', dtmf: '1', greetingPauseSec: 5, answerWaitSec: 45 },
|
||||||
|
});
|
||||||
|
|
||||||
|
handleVoiceWebhook(TEST_ID, 'store', 'wait');
|
||||||
|
let s = getSession(TEST_ID);
|
||||||
|
assert.equal(s.lastStep, 'wait');
|
||||||
|
assert.equal(s.reachedCore, false);
|
||||||
|
|
||||||
|
handleVoiceWebhook(TEST_ID, 'store', 'intro');
|
||||||
|
s = getSession(TEST_ID);
|
||||||
|
assert.equal(s.lastStep, 'intro');
|
||||||
|
assert.equal(s.reachedCore, true);
|
||||||
|
assert.ok(s.events.coreStartedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleVoiceWebhook done step finalizes without status callback', async () => {
|
||||||
|
_clearAllSessionsForTests();
|
||||||
|
_clearPendingFinalizeTimersForTests();
|
||||||
|
createSession(TEST_ID, {
|
||||||
|
mode: 'dial',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
roomId: 'room-1',
|
||||||
|
reachedCore: true,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
|
||||||
|
handleVoiceWebhook(TEST_ID, 'dial', 'done');
|
||||||
|
|
||||||
|
const pending = getSession(TEST_ID);
|
||||||
|
assert.equal(pending.lastStep, 'done');
|
||||||
|
|
||||||
|
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
|
||||||
|
assert.equal(finalized.result, 'pass');
|
||||||
|
assert.equal(finalized.status, 'completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => {
|
||||||
|
_clearAllSessionsForTests();
|
||||||
|
createSession(TEST_ID, {
|
||||||
|
mode: 'dial',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
reachedCore: true,
|
||||||
|
lastStep: 'done',
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
CallStatus: 'in-progress',
|
||||||
|
CallSid: 'CA123',
|
||||||
|
});
|
||||||
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
CallStatus: 'completed',
|
||||||
|
CallDuration: '75',
|
||||||
|
CallSid: 'CA123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = getSession(TEST_ID);
|
||||||
|
assert.ok(s.events.answered);
|
||||||
|
assert.equal(s.result, 'pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
|
||||||
|
_clearAllSessionsForTests();
|
||||||
|
createSession(TEST_ID, {
|
||||||
|
mode: 'dial',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
reachedCore: true,
|
||||||
|
lastStep: 'done',
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
CallStatus: 'answered',
|
||||||
|
CallSid: 'CA123',
|
||||||
|
});
|
||||||
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
CallStatus: 'completed',
|
||||||
|
CallDuration: '75',
|
||||||
|
CallSid: 'CA123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = getSession(TEST_ID);
|
||||||
|
assert.equal(s.result, 'pass');
|
||||||
|
assert.equal(s.durationSec, 75);
|
||||||
|
|
||||||
|
const md = renderCallTestResultMarkdown(s);
|
||||||
|
assert.match(md, /PASSED/);
|
||||||
|
assert.match(md, /CA123/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleStatusCallback: no-answer fails', async () => {
|
||||||
|
_clearAllSessionsForTests();
|
||||||
|
createSession(TEST_ID, {
|
||||||
|
mode: 'dial',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleStatusCallback(TEST_ID, {
|
||||||
|
CallStatus: 'no-answer',
|
||||||
|
CallSid: 'CA999',
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = getSession(TEST_ID);
|
||||||
|
assert.equal(s.result, 'fail');
|
||||||
|
const md = renderCallTestResultMarkdown(s);
|
||||||
|
assert.match(md, /FAILED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCallTestResultMarkdown shows in progress before final result', () => {
|
||||||
|
const md = renderCallTestResultMarkdown({
|
||||||
|
mode: 'dial',
|
||||||
|
status: 'in_progress',
|
||||||
|
dialNumber: '+12125550100',
|
||||||
|
testId: TEST_ID,
|
||||||
|
events: { coreStartedAt: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
assert.match(md, /IN PROGRESS/);
|
||||||
|
});
|
||||||
97
tests/callTest.signature.test.js
Normal file
97
tests/callTest.signature.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// tests/callTest.signature.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import twilio from 'twilio';
|
||||||
|
|
||||||
|
import { validateTwilioSignature } from '../integrations/twilio/client.js';
|
||||||
|
import { resolveCallTestWebhookUrl } from '../services/callTest/config.js';
|
||||||
|
|
||||||
|
test('validateTwilioSignature matches Twilio HMAC algorithm', () => {
|
||||||
|
const authToken = 'test-auth-token';
|
||||||
|
const url = 'https://example.com/twilio/calltest/status?testId=abc';
|
||||||
|
const params = { CallStatus: 'completed', CallSid: 'CA123' };
|
||||||
|
|
||||||
|
let data = url;
|
||||||
|
for (const key of Object.keys(params).sort()) {
|
||||||
|
data += key + params[key];
|
||||||
|
}
|
||||||
|
const signature = crypto.createHmac('sha1', authToken).update(data, 'utf-8').digest('base64');
|
||||||
|
|
||||||
|
const prevSid = process.env.TWILIO_ACCOUNT_SID;
|
||||||
|
const prevToken = process.env.TWILIO_AUTH_TOKEN;
|
||||||
|
const prevFrom = process.env.TWILIO_FROM_NUMBER;
|
||||||
|
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
|
||||||
|
process.env.TWILIO_ACCOUNT_SID = 'ACtest';
|
||||||
|
process.env.TWILIO_AUTH_TOKEN = authToken;
|
||||||
|
process.env.TWILIO_FROM_NUMBER = '+15550001111';
|
||||||
|
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(validateTwilioSignature(signature, url, params), true);
|
||||||
|
assert.equal(validateTwilioSignature('bad', url, params), false);
|
||||||
|
} finally {
|
||||||
|
if (prevSid == null) delete process.env.TWILIO_ACCOUNT_SID;
|
||||||
|
else process.env.TWILIO_ACCOUNT_SID = prevSid;
|
||||||
|
if (prevToken == null) delete process.env.TWILIO_AUTH_TOKEN;
|
||||||
|
else process.env.TWILIO_AUTH_TOKEN = prevToken;
|
||||||
|
if (prevFrom == null) delete process.env.TWILIO_FROM_NUMBER;
|
||||||
|
else process.env.TWILIO_FROM_NUMBER = prevFrom;
|
||||||
|
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveCallTestWebhookUrl rebuilds the registered Twilio voice URL', () => {
|
||||||
|
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://proxy.example.com/CollabSupport';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const req = {
|
||||||
|
path: '/twilio/calltest/voice',
|
||||||
|
originalUrl: '/twilio/calltest/voice?testId=abc&mode=dial&step=intro',
|
||||||
|
query: { testId: 'abc', mode: 'dial', step: 'intro' },
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
resolveCallTestWebhookUrl(req),
|
||||||
|
'https://proxy.example.com/CollabSupport/twilio/calltest/voice?testId=abc&mode=dial&step=intro',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateTwilioSignature accepts array POST params like Twilio SDK', () => {
|
||||||
|
const authToken = 'test-auth-token';
|
||||||
|
const url = 'https://example.com/twilio/calltest/status?testId=abc';
|
||||||
|
const params = { Tags: ['a', 'b'], CallStatus: 'completed' };
|
||||||
|
|
||||||
|
const signature = twilio.getExpectedTwilioSignature(authToken, url, params);
|
||||||
|
|
||||||
|
const prevSid = process.env.TWILIO_ACCOUNT_SID;
|
||||||
|
const prevToken = process.env.TWILIO_AUTH_TOKEN;
|
||||||
|
const prevFrom = process.env.TWILIO_FROM_NUMBER;
|
||||||
|
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
|
||||||
|
process.env.TWILIO_ACCOUNT_SID = 'ACtest';
|
||||||
|
process.env.TWILIO_AUTH_TOKEN = authToken;
|
||||||
|
process.env.TWILIO_FROM_NUMBER = '+15550001111';
|
||||||
|
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(validateTwilioSignature(signature, url, params), true);
|
||||||
|
} finally {
|
||||||
|
if (prevSid == null) delete process.env.TWILIO_ACCOUNT_SID;
|
||||||
|
else process.env.TWILIO_ACCOUNT_SID = prevSid;
|
||||||
|
if (prevToken == null) delete process.env.TWILIO_AUTH_TOKEN;
|
||||||
|
else process.env.TWILIO_AUTH_TOKEN = prevToken;
|
||||||
|
if (prevFrom == null) delete process.env.TWILIO_FROM_NUMBER;
|
||||||
|
else process.env.TWILIO_FROM_NUMBER = prevFrom;
|
||||||
|
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
|
||||||
|
}
|
||||||
|
});
|
||||||
108
tests/callTest.twiml.test.js
Normal file
108
tests/callTest.twiml.test.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// tests/callTest.twiml.test.js
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildTwiml,
|
||||||
|
initialStepForMode,
|
||||||
|
CORE_STEP_ORDER,
|
||||||
|
STORE_ENTRY_STEP_ORDER,
|
||||||
|
} from '../services/callTest/twiml.js';
|
||||||
|
import {
|
||||||
|
buildVoiceWebhookUrl,
|
||||||
|
getCallTestConfig,
|
||||||
|
normalizeE164,
|
||||||
|
} from '../services/callTest/config.js';
|
||||||
|
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
|
||||||
|
|
||||||
|
const TEST_ID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
const CFG = {
|
||||||
|
dtmf: '1',
|
||||||
|
greetingPauseSec: 5,
|
||||||
|
answerWaitSec: 45,
|
||||||
|
listenSec: 60,
|
||||||
|
introTts: 'Intro message',
|
||||||
|
outroTts: 'Outro message',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('initialStepForMode: store starts at greeting, dial at intro', () => {
|
||||||
|
assert.equal(initialStepForMode('store'), 'greeting');
|
||||||
|
assert.equal(initialStepForMode('dial'), 'intro');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTwiml: store entry chain references next steps', () => {
|
||||||
|
const greeting = buildTwiml({ mode: 'store', step: 'greeting', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(greeting, /<Pause length="5"\/>/);
|
||||||
|
assert.match(greeting, /step=dtmf/);
|
||||||
|
|
||||||
|
const dtmf = buildTwiml({ mode: 'store', step: 'dtmf', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(dtmf, /<Play digits="wwww1"\/>/);
|
||||||
|
assert.match(dtmf, /step=wait/);
|
||||||
|
|
||||||
|
const wait = buildTwiml({ mode: 'store', step: 'wait', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(wait, /<Pause length="45"\/>/);
|
||||||
|
assert.match(wait, /step=intro/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTwiml: shared core chain', () => {
|
||||||
|
for (const step of CORE_STEP_ORDER) {
|
||||||
|
const xml = buildTwiml({ mode: 'dial', step, testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(xml, /^<\?xml/);
|
||||||
|
assert.match(xml, /<Response>/);
|
||||||
|
if (step === 'done') {
|
||||||
|
assert.match(xml, /<Hangup\/>/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const intro = buildTwiml({ mode: 'dial', step: 'intro', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(intro, /Intro message/);
|
||||||
|
assert.match(intro, /step=listen/);
|
||||||
|
|
||||||
|
const listen = buildTwiml({ mode: 'dial', step: 'listen', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(listen, /<Pause length="60"\/>/);
|
||||||
|
|
||||||
|
const outro = buildTwiml({ mode: 'dial', step: 'outro', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(outro, /Outro message/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTwiml: store entry order ends at intro then core', () => {
|
||||||
|
assert.deepEqual(STORE_ENTRY_STEP_ORDER, ['greeting', 'dtmf', 'wait']);
|
||||||
|
const last = buildTwiml({ mode: 'store', step: 'wait', testId: TEST_ID, config: CFG });
|
||||||
|
assert.match(last, /step=intro/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeE164', () => {
|
||||||
|
assert.equal(normalizeE164('2125550100'), '+12125550100');
|
||||||
|
assert.equal(normalizeE164('+12125550100'), '+12125550100');
|
||||||
|
assert.equal(normalizeE164('bad'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCallTestArgs', () => {
|
||||||
|
assert.deepEqual(parseCallTestArgs(['782']), { kind: 'store', storeNum: '782', dialOverride: null });
|
||||||
|
assert.deepEqual(parseCallTestArgs(['store', '782']), { kind: 'store', storeNum: '782', dialOverride: null });
|
||||||
|
assert.deepEqual(parseCallTestArgs(['dial', '+12125550100']), { kind: 'dial', dial: '+12125550100' });
|
||||||
|
assert.deepEqual(parseCallTestArgs(['+12125550100']), { kind: 'dial', dial: '+12125550100' });
|
||||||
|
assert.deepEqual(parseCallTestArgs(['status', 'abc']), { kind: 'status', testId: 'abc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildVoiceWebhookUrl uses TWILIO_WEBHOOK_BASE_URL', () => {
|
||||||
|
const prev = process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
|
||||||
|
try {
|
||||||
|
const url = buildVoiceWebhookUrl(TEST_ID, 'store', 'greeting');
|
||||||
|
assert.match(url, /^https:\/\/example\.com\/twilio\/calltest\/voice\?/);
|
||||||
|
assert.match(url, /testId=11111111/);
|
||||||
|
assert.match(url, /mode=store/);
|
||||||
|
assert.match(url, /step=greeting/);
|
||||||
|
} finally {
|
||||||
|
if (prev == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
||||||
|
else process.env.TWILIO_WEBHOOK_BASE_URL = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCallTestConfig defaults listen to 60', () => {
|
||||||
|
const cfg = getCallTestConfig();
|
||||||
|
assert.equal(cfg.listenSec, 60);
|
||||||
|
assert.equal(cfg.dtmf, '1');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue