diff --git a/.env.example b/.env.example index bfe19e1..a88f3d7 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ LOG_LEVEL=info # HTTP API authentication # ----------------------------------------------------------------------------- # 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 # with HTTP 503 — set this to any high-entropy string (e.g. `openssl rand -hex 32`). # Callers send the token as `Authorization: Bearer ` or `X-API-Token: `. @@ -467,6 +467,38 @@ DECT_RELAY_AGENT_TOKEN= # Optional. Default: /dect-relay/ws. Change only if you also change # DECT_RELAY_BOT_URL on the agent side to match. # 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 # DBS-210 reads slow; 15s is comfortable, 30s is generous. # DECT_COLLECT_TIMEOUT_MS=15000 diff --git a/commands/callTest.js b/commands/callTest.js new file mode 100644 index 0000000..c195d90 --- /dev/null +++ b/commands/callTest.js @@ -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 `\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 `\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}`); + } +} diff --git a/commands/help.js b/commands/help.js index 00ac8cf..30dd908 100644 --- a/commands/help.js +++ b/commands/help.js @@ -21,6 +21,7 @@ const SHORT_HELP = { phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)', dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset 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 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.', ], }, + calltest: { + title: '/calltest', + usage: [ + '/calltest ', + '/calltest store [+1...]', + '/calltest dial ', + '/calltest status ', + ], + 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: { title: '/wohistory', usage: ['/wohistory '], @@ -253,7 +277,7 @@ const LONG_HELP = { const GROUPS = [ { 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: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, diff --git a/commands/registry.js b/commands/registry.js index 25f9ff6..7f8d7b3 100644 --- a/commands/registry.js +++ b/commands/registry.js @@ -30,6 +30,7 @@ import { handleVoiceDiag } from './voiceDiag.js'; import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js'; import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js'; import { handleTestDevicesByModel } from './testDevicesByModel.js'; +import { handleCallTest } from './callTest.js'; /** * Each entry: @@ -69,6 +70,8 @@ export const commands = [ { name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true }, { name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, 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: 'webexhost', handler: handleWebexHost, mutating: true }, // bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment, diff --git a/config/calltest-stores.json b/config/calltest-stores.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/config/calltest-stores.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/index.js b/index.js index 0494c2a..95e462e 100644 --- a/index.js +++ b/index.js @@ -52,6 +52,8 @@ import { ALL_HTTP_COMMAND_KEYS, MUTATING_COMMAND_KEYS, } from './commands/registry.js'; +import twilioCallTestRouter from './routes/twilioCallTest.js'; +import { isCallTestConfigured } from './services/callTest/callTestService.js'; // ────────────────────────────────────────────── // Express setup @@ -83,6 +85,9 @@ app.use((req, res, next) => { next(); }); +// Twilio /calltest webhooks — after path-prefix strip, before /:command catch-all. +app.use(twilioCallTestRouter); + // Health check app.get('/health', (req, res) => { 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 // ────────────────────────────────────────────── diff --git a/integrations/twilio/client.js b/integrations/twilio/client.js new file mode 100644 index 0000000..c652da6 --- /dev/null +++ b/integrations/twilio/client.js @@ -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, +}; diff --git a/package-lock.json b/package-lock.json index 86661b5..1132c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "form-data": "^4.0.5", "graphql-request": "^7.4.0", "node-cron": "^4.2.1", + "twilio": "^6.0.2", "webex-node-bot-framework": "^2.5.1", "ws": "^8.21.0" }, @@ -6020,6 +6021,12 @@ "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -11123,6 +11130,13 @@ "optional": 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": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", @@ -11976,6 +11990,33 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/package.json b/package.json index be8a14a..3dad156 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "form-data": "^4.0.5", "graphql-request": "^7.4.0", "node-cron": "^4.2.1", + "twilio": "^6.0.2", "webex-node-bot-framework": "^2.5.1", "ws": "^8.21.0" }, diff --git a/routes/twilioCallTest.js b/routes/twilioCallTest.js new file mode 100644 index 0000000..54596f3 --- /dev/null +++ b/routes/twilioCallTest.js @@ -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( + 'Missing test parameters.', + ); + } + + 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; diff --git a/services/callTest/callTestService.js b/services/callTest/callTestService.js new file mode 100644 index 0000000..7a54000 --- /dev/null +++ b/services/callTest/callTestService.js @@ -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} 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(); +} diff --git a/services/callTest/config.js b/services/callTest/config.js new file mode 100644 index 0000000..33acf12 --- /dev/null +++ b/services/callTest/config.js @@ -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; +} diff --git a/services/callTest/parseArgs.js b/services/callTest/parseArgs.js new file mode 100644 index 0000000..8d827e5 --- /dev/null +++ b/services/callTest/parseArgs.js @@ -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' }; +} diff --git a/services/callTest/sessionStore.js b/services/callTest/sessionStore.js new file mode 100644 index 0000000..d840288 --- /dev/null +++ b/services/callTest/sessionStore.js @@ -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(); +} diff --git a/services/callTest/storeResolver.js b/services/callTest/storeResolver.js new file mode 100644 index 0000000..397e31f --- /dev/null +++ b/services/callTest/storeResolver.js @@ -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, + }; +} diff --git a/services/callTest/twiml.js b/services/callTest/twiml.js new file mode 100644 index 0000000..19c07a8 --- /dev/null +++ b/services/callTest/twiml.js @@ -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, '''); +} + +function redirect(testId, mode, step) { + const url = xmlEscape(buildVoiceWebhookUrl(testId, mode, step)); + return `${url}`; +} + +/** + * @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 = `\n${redirect(testId, mode, 'dtmf')}`; + break; + case 'dtmf': { + const digits = String(c.dtmf || '1').replace(/[^0-9*#w]/gi, ''); + body = `\n${redirect(testId, mode, 'wait')}`; + break; + } + case 'wait': + body = `\n${redirect(testId, mode, 'intro')}`; + break; + default: + body = `Unknown entry step.\n`; + } + } else if (CORE_STEPS.has(step)) { + switch (step) { + case 'intro': + body = `${xmlEscape(c.introTts)}\n${redirect(testId, mode, 'listen')}`; + break; + case 'listen': + body = `\n${redirect(testId, mode, 'outro')}`; + break; + case 'outro': + body = `${xmlEscape(c.outroTts)}\n${redirect(testId, mode, 'done')}`; + break; + case 'done': + body = ''; + break; + default: + body = ''; + } + } else if (mode === 'dial' && step === 'intro') { + body = `${xmlEscape(c.introTts)}\n${redirect(testId, mode, 'listen')}`; + } else { + body = 'Invalid call test step.\n'; + } + + return `\n\n${body}\n`; +} + +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']; diff --git a/services/renderers/callTestRenderer.js b/services/renderers/callTestRenderer.js new file mode 100644 index 0000000..a1ebe3c --- /dev/null +++ b/services/renderers/callTestRenderer.js @@ -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(); +} diff --git a/tests/callTest.service.test.js b/tests/callTest.service.test.js new file mode 100644 index 0000000..2edbc57 --- /dev/null +++ b/tests/callTest.service.test.js @@ -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/); +}); diff --git a/tests/callTest.signature.test.js b/tests/callTest.signature.test.js new file mode 100644 index 0000000..55e4be1 --- /dev/null +++ b/tests/callTest.signature.test.js @@ -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; + } +}); diff --git a/tests/callTest.twiml.test.js b/tests/callTest.twiml.test.js new file mode 100644 index 0000000..df2946b --- /dev/null +++ b/tests/callTest.twiml.test.js @@ -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, //); + assert.match(greeting, /step=dtmf/); + + const dtmf = buildTwiml({ mode: 'store', step: 'dtmf', testId: TEST_ID, config: CFG }); + assert.match(dtmf, //); + assert.match(dtmf, /step=wait/); + + const wait = buildTwiml({ mode: 'store', step: 'wait', testId: TEST_ID, config: CFG }); + assert.match(wait, //); + 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, //); + if (step === 'done') { + assert.match(xml, //); + } + } + + 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, //); + + 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'); +});